/** * This file is part of alf.io. * * alf.io is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * alf.io is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with alf.io. If not, see <http://www.gnu.org/licenses/>. */ package alfio.manager; import alfio.model.*; import alfio.manager.system.ConfigurationManager; import alfio.model.Event; import alfio.model.system.Configuration; import alfio.model.system.ConfigurationKeys; import alfio.repository.TicketRepository; import alfio.repository.TicketReservationRepository; import alfio.util.MonetaryUtil; import com.paypal.api.payments.*; import com.paypal.api.payments.Transaction; import com.paypal.base.rest.APIContext; import com.paypal.base.rest.PayPalRESTException; import lombok.extern.log4j.Log4j2; import org.apache.commons.codec.digest.HmacUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.DateUtils; import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.stereotype.Component; import org.springframework.web.util.UriComponentsBuilder; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import static alfio.util.MonetaryUtil.formatCents; @Component @Log4j2 public class PaypalManager { private final ConfigurationManager configurationManager; private final MessageSource messageSource; private final ConcurrentHashMap<String, String> cachedWebProfiles = new ConcurrentHashMap<>(); private final TicketReservationRepository ticketReservationRepository; @Autowired public PaypalManager(ConfigurationManager configurationManager, TicketReservationRepository ticketReservationRepository, MessageSource messageSource, TicketRepository ticketRepository) { this.configurationManager = configurationManager; this.messageSource = messageSource; this.ticketReservationRepository = ticketReservationRepository; } private APIContext getApiContext(Event event) { int orgId = event.getOrganizationId(); boolean isLive = configurationManager.getBooleanConfigValue(Configuration.from(orgId, ConfigurationKeys.PAYPAL_LIVE_MODE), false); String clientId = configurationManager.getRequiredValue(Configuration.from(orgId, ConfigurationKeys.PAYPAL_CLIENT_ID)); String clientSecret = configurationManager.getRequiredValue(Configuration.from(orgId, ConfigurationKeys.PAYPAL_CLIENT_SECRET)); return new APIContext(clientId, clientSecret, isLive ? "live" : "sandbox"); } private static String toWebProfileName(Event event, Locale locale) { return "ALFIO-" + event.getId() + "-" + event.getShortName() + "-" + locale.toString(); } private Optional<WebProfile> getWebProfile(Event event, Locale locale) { try { String webProfileName = toWebProfileName(event, locale); return WebProfile.getList(getApiContext(event)).stream().filter(webProfile -> webProfileName.equals(webProfile.getName())).findFirst(); } catch(PayPalRESTException e) { return Optional.empty(); } } private Optional<String> getOrCreateWebProfile(Event event, Locale locale) { String webProfileName = toWebProfileName(event, locale); if(!cachedWebProfiles.containsKey(webProfileName)) { getWebProfile(event, locale).ifPresent(p -> { cachedWebProfiles.put(webProfileName, p.getId()); }); } if(!cachedWebProfiles.containsKey(webProfileName)) { WebProfile webProfile = new WebProfile(webProfileName); webProfile.setInputFields(new InputFields().setNoShipping(1).setAddressOverride(0).setAllowNote(false)); // meh // webProfile.setPresentation(new Presentation().setLocaleCode(locale.toString())); // try { cachedWebProfiles.put(webProfileName, webProfile.create(getApiContext(event)).getId()); } catch(PayPalRESTException e) { log.warn("error while creating web experience", e); //do absolutely nothing, worst case: the web experience will not be optimal } // } return Optional.ofNullable(cachedWebProfiles.get(webProfileName)); } private List<Transaction> buildPaymentDetails(Event event, OrderSummary orderSummary, String reservationId, Locale locale) { Amount amount = new Amount() .setCurrency(event.getCurrency()) .setTotal(orderSummary.getTotalPrice()); Transaction transaction = new Transaction(); String description = messageSource.getMessage("reservation-email-subject", new Object[] {configurationManager.getShortReservationID(event, reservationId), event.getDisplayName()}, locale); transaction.setDescription(description).setAmount(amount); List<Item> items = new ArrayList<>(); items.add(new Item(description, "1", orderSummary.getTotalPrice(), event.getCurrency())); transaction.setItemList(new ItemList().setItems(items)); List<Transaction> transactions = new ArrayList<>(); transactions.add(transaction); return transactions; } public String createCheckoutRequest(Event event, String reservationId, OrderSummary orderSummary, CustomerName customerName, String email, String billingAddress, Locale locale, boolean postponeAssignment) throws Exception { Optional<String> experienceProfileId = getOrCreateWebProfile(event, locale); List<Transaction> transactions = buildPaymentDetails(event, orderSummary, reservationId, locale); String eventName = event.getShortName(); Payer payer = new Payer(); payer.setPaymentMethod("paypal"); Payment payment = new Payment(); payment.setIntent("sale"); payment.setPayer(payer); payment.setTransactions(transactions); RedirectUrls redirectUrls = new RedirectUrls(); String baseUrl = StringUtils.removeEnd(configurationManager.getRequiredValue(Configuration.from(event.getOrganizationId(), event.getId(), ConfigurationKeys.BASE_URL)), "/"); String bookUrl = baseUrl+"/event/" + eventName + "/reservation/" + reservationId + "/book"; UriComponentsBuilder bookUrlBuilder = UriComponentsBuilder.fromUriString(bookUrl) .queryParam("fullName", customerName.getFullName()) .queryParam("firstName", customerName.getFirstName()) .queryParam("lastName", customerName.getLastName()) .queryParam("email", email) .queryParam("billingAddress", billingAddress) .queryParam("postponeAssignment", postponeAssignment) .queryParam("hmac", computeHMAC(customerName, email, billingAddress, event)); String finalUrl = bookUrlBuilder.toUriString(); redirectUrls.setCancelUrl(finalUrl + "&paypal-cancel=true"); redirectUrls.setReturnUrl(finalUrl + "&paypal-success=true"); payment.setRedirectUrls(redirectUrls); experienceProfileId.ifPresent(payment::setExperienceProfileId); Payment createdPayment = payment.create(getApiContext(event)); TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); //add 15 minutes of validity in case the paypal flow is slow ticketReservationRepository.updateValidity(reservationId, DateUtils.addMinutes(reservation.getValidity(), 15)); if(!"created".equals(createdPayment.getState())) { throw new Exception(createdPayment.getFailureReason()); } //extract url for approval return createdPayment.getLinks().stream().filter(l -> "approval_url".equals(l.getRel())).findFirst().map(Links::getHref).orElseThrow(IllegalStateException::new); } private static String computeHMAC(CustomerName customerName, String email, String billingAddress, Event event) { return HmacUtils.hmacSha256Hex(event.getPrivateKey(), StringUtils.trimToEmpty(customerName.getFullName()) + StringUtils.trimToEmpty(email) + StringUtils.trimToEmpty(billingAddress)); } public static boolean isValidHMAC(CustomerName customerName, String email, String billingAddress, String hmac, Event event) { String computedHmac = computeHMAC(customerName, email, billingAddress, event); return MessageDigest.isEqual(hmac.getBytes(StandardCharsets.UTF_8), computedHmac.getBytes(StandardCharsets.UTF_8)); } public Pair<String, String> commitPayment(String reservationId, String token, String payerId, Event event) throws PayPalRESTException { Payment payment = new Payment().setId(token); PaymentExecution paymentExecute = new PaymentExecution(); paymentExecute.setPayerId(payerId); Payment result = payment.execute(getApiContext(event), paymentExecute); // state can only be "created", "approved" or "failed". // if we are at this stage, the only possible options are approved or failed, thus it's safe to re transition the reservation to a pending status: no payment has been made! if(!"approved".equals(result.getState())) { log.warn("error in state for reservationId {}, expected 'approved' state, but got '{}', failure reason is {}", reservationId, result.getState(), result.getFailureReason()); throw new PayPalRESTException(result.getFailureReason()); } // navigate the object graph (ideally taking the first Sale object) result.getTransactions().get(0).getRelatedResources().get(0).getSale().getId() String captureId = result.getTransactions().stream() .map(Transaction::getRelatedResources) .flatMap(List::stream) .map(RelatedResources::getSale) .filter(Objects::nonNull) .map(Sale::getId) .filter(Objects::nonNull) .findFirst().orElseThrow(IllegalStateException::new); return Pair.of(captureId, payment.getId()); } public Optional<PaymentInformations> getInfo(alfio.model.transaction.Transaction transaction, Event event) { try { String refund = null; //check for backward compatibility reason... if(transaction.getPaymentId() != null) { //navigate in all refund objects and sum their amount refund = Payment.get(getApiContext(event), transaction.getPaymentId()).getTransactions().stream() .map(Transaction::getRelatedResources) .flatMap(List::stream) .filter(f -> f.getRefund() != null) .map(RelatedResources::getRefund) .map(Refund::getAmount) .map(Amount::getTotal) .map(BigDecimal::new) .reduce(BigDecimal.ZERO, BigDecimal::add).toPlainString(); // } Capture c = Capture.get(getApiContext(event), transaction.getTransactionId()); return Optional.ofNullable(new PaymentInformations(c.getAmount().getTotal(), refund)); } catch (PayPalRESTException ex) { log.warn("Paypal: error while fetching information for payment id " + transaction.getTransactionId(), ex); return Optional.empty(); } } public boolean refund(alfio.model.transaction.Transaction transaction, Event event, Optional<Integer> amount) { String captureId = transaction.getTransactionId(); try { String amountOrFull = amount.map(MonetaryUtil::formatCents).orElse("full"); log.info("Paypal: trying to do a refund for payment {} with amount: {}", captureId, amountOrFull); Capture capture = Capture.get(getApiContext(event), captureId); RefundRequest refundRequest = new RefundRequest(); amount.ifPresent(a -> refundRequest.setAmount(new Amount(capture.getAmount().getCurrency(), formatCents(a)))); DetailedRefund res = capture.refund(getApiContext(event), refundRequest); log.info("Paypal: refund for payment {} executed with success for amount: {}", captureId, amountOrFull); return true; } catch(PayPalRESTException ex) { log.warn("Paypal: was not able to refund payment with id " + captureId, ex); return false; } } }