alfio.manager.PaypalManager.java Source code

Java tutorial

Introduction

Here is the source code for alfio.manager.PaypalManager.java

Source

/**
 * 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.manager.support.FeeCalculator;
import alfio.manager.system.ConfigurationManager;
import alfio.model.*;
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.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.paypal.api.payments.*;
import com.paypal.api.payments.Currency;
import com.paypal.base.rest.APIContext;
import com.paypal.base.rest.PayPalRESTException;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.codec.digest.HmacAlgorithms;
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.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.TimeUnit;
import java.util.function.Supplier;

import static alfio.util.MonetaryUtil.formatCents;

@Component
@Log4j2
@AllArgsConstructor
public class PaypalManager {

    private final ConfigurationManager configurationManager;
    private final MessageSource messageSource;
    private final Cache<String, String> cachedWebProfiles = Caffeine.newBuilder()
            .expireAfterAccess(24, TimeUnit.HOURS).build();
    private final TicketReservationRepository ticketReservationRepository;
    private final TicketRepository ticketRepository;

    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, APIContext apiContext) {
        return "ALFIO-"
                + DigestUtils.md5Hex(apiContext.getClientID() + "-" + event.getId() + "-" + event.getShortName())
                + "-" + locale.toString();
    }

    private Optional<WebProfile> getWebProfile(Event event, Locale locale, APIContext apiContext) {
        try {
            String webProfileName = toWebProfileName(event, locale, apiContext);
            return WebProfile.getList(apiContext).stream()
                    .filter(webProfile -> webProfileName.equals(webProfile.getName())).findFirst();
        } catch (PayPalRESTException e) {
            return Optional.empty();
        }
    }

    private Optional<String> getOrCreateWebProfile(Event event, Locale locale, APIContext apiContext) {
        String webProfileName = toWebProfileName(event, locale, apiContext);

        String id = cachedWebProfiles.get(webProfileName, missingKey -> {
            String profileId = getWebProfile(event, locale, apiContext).map(WebProfile::getId).orElseGet(() -> {
                //create profile
                WebProfile webProfile = new WebProfile(webProfileName);
                webProfile.setInputFields(
                        new InputFields().setNoShipping(1).setAddressOverride(0).setAllowNote(false));
                try {
                    return webProfile.create(apiContext).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 null;
                }
            });

            return profileId;
        });
        return Optional.ofNullable(id);
    }

    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 {

        APIContext apiContext = getApiContext(event);

        Optional<String> experienceProfileId = getOrCreateWebProfile(event, locale, apiContext);

        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(apiContext);

        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 new HmacUtils(HmacAlgorithms.HMAC_SHA_256, event.getPrivateKey())
                .hmacHex(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 static class HandledPaypalErrorException extends RuntimeException {
        HandledPaypalErrorException(String errorMessage) {
            super(errorMessage);
        }
    }

    private static final Set<String> MAPPED_ERROR = Collections.unmodifiableSet(new HashSet<>(Arrays
            .asList("FAILED_TO_CHARGE_CC", "INSUFFICIENT_FUNDS", "EXPIRED_CREDIT_CARD", "INSTRUMENT_DECLINED")));

    private static Optional<String> mappedException(PayPalRESTException e) {
        //https://developer.paypal.com/docs/api/#errors
        if (e.getDetails() != null && e.getDetails().getName() != null
                && MAPPED_ERROR.contains(e.getDetails().getName())) {
            return Optional.of("error.STEP_2_PAYPAL_" + e.getDetails().getName());
        } else {
            return Optional.empty();
        }
    }

    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 = null;
        try {
            result = payment.execute(getApiContext(event), paymentExecute);
        } catch (PayPalRESTException e) {
            mappedException(e).ifPresent(message -> {
                throw new HandledPaypalErrorException(message);
            });
            throw e;
        }

        // 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());
    }

    Optional<PaymentInformation> getInfo(String paymentId, String transactionId, Event event,
            Supplier<String> platformFeeSupplier) {
        try {
            String refund = null;

            APIContext apiContext = getApiContext(event);

            //check for backward compatibility  reason...
            if (paymentId != null) {
                //navigate in all refund objects and sum their amount
                refund = Payment.get(apiContext, paymentId).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(apiContext, transactionId);
            String gatewayFee = Optional.ofNullable(c.getTransactionFee()).map(Currency::getValue)
                    .map(BigDecimal::new).map(MonetaryUtil::unitToCents).map(String::valueOf).orElse(null);
            return Optional.of(new PaymentInformation(c.getAmount().getTotal(), refund, gatewayFee,
                    platformFeeSupplier.get()));
        } catch (PayPalRESTException ex) {
            log.warn("Paypal: error while fetching information for payment id " + transactionId, ex);
            return Optional.empty();
        }
    }

    Optional<PaymentInformation> getInfo(alfio.model.transaction.Transaction transaction, Event event) {
        return getInfo(transaction.getPaymentId(), transaction.getTransactionId(), event, () -> {
            if (transaction.getPlatformFee() > 0) {
                return String.valueOf(transaction.getPlatformFee());
            }
            return FeeCalculator.getCalculator(event, configurationManager)
                    .apply(ticketRepository.countTicketsInReservation(transaction.getReservationId()),
                            (long) transaction.getPriceInCents())
                    .map(String::valueOf).orElse("0");
        });
    }

    public boolean refund(alfio.model.transaction.Transaction transaction, Event event, Optional<Integer> amount) {
        String captureId = transaction.getTransactionId();
        try {
            APIContext apiContext = getApiContext(event);
            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(apiContext, captureId);
            RefundRequest refundRequest = new RefundRequest();
            amount.ifPresent(
                    a -> refundRequest.setAmount(new Amount(capture.getAmount().getCurrency(), formatCents(a))));
            DetailedRefund res = capture.refund(apiContext, 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;
        }
    }
}