/** * 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.payment; import alfio.manager.support.FeeCalculator; import alfio.manager.support.PaymentResult; import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.user.UserManager; import alfio.model.Event; import alfio.model.EventAndOrganizationId; import alfio.model.PaymentInformation; import alfio.model.system.ConfigurationKeys; import alfio.model.system.ConfigurationPathLevel; import alfio.model.transaction.PaymentContext; import alfio.model.transaction.PaymentMethod; import alfio.model.transaction.PaymentProxy; import alfio.model.transaction.Transaction; import alfio.repository.TicketRepository; import alfio.repository.system.ConfigurationRepository; import alfio.util.ErrorsCode; import alfio.util.MonetaryUtil; import com.stripe.Stripe; import com.stripe.exception.*; import com.stripe.model.BalanceTransaction; import com.stripe.model.Charge; import com.stripe.model.Refund; import com.stripe.net.RequestOptions; import com.stripe.net.Webhook; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; import java.util.*; import java.util.function.Predicate; import java.util.function.UnaryOperator; import static alfio.model.system.ConfigurationKeys.*; @Log4j2 @AllArgsConstructor class BaseStripeManager { static final String STRIPE_MANAGER_TYPE_KEY = "stripeManagerType"; static final String SUCCEEDED = "succeeded"; static final String PENDING = "pending"; private final ConfigurationManager configurationManager; private final ConfigurationRepository configurationRepository; private final TicketRepository ticketRepository; private final Environment environment; private final Map<Class<? extends StripeException>, StripeExceptionHandler> handlers = Map.of( CardException.class, this::handleCardException, InvalidRequestException.class, this::handleInvalidRequestException, AuthenticationException.class, this::handleAuthenticationException, ApiConnectionException.class, this::handleApiConnectionException, StripeException.class, this::handleGenericException ); static { Stripe.setAppInfo("Alf.io", "2.x", "https://alf.io"); } String getSecretKey(EventAndOrganizationId event) { return configurationManager.getFor(STRIPE_SECRET_KEY, ConfigurationLevel.event(event)).getRequiredValue(); } String getWebhookSignatureKey() { return configurationManager.getForSystem(STRIPE_WEBHOOK_KEY).getRequiredValue(); } String getPublicKey(PaymentContext context) { if(isConnectEnabled(context)) { return configurationManager.getForSystem(STRIPE_PUBLIC_KEY).getRequiredValue(); } return configurationManager.getFor(STRIPE_PUBLIC_KEY, context.getConfigurationLevel()).getRequiredValue(); } Map<String, ?> getModelOptions(PaymentContext context) { Map<String, Object> options = new HashMap<>(); options.put("enableSCA", configurationManager.getFor(STRIPE_ENABLE_SCA, context.getConfigurationLevel()).getValueAsBooleanOrDefault(false)); options.put("stripe_p_key", getPublicKey(context)); return options; } private boolean isConnectEnabled(PaymentContext context) { return configurationManager.getFor(PLATFORM_MODE_ENABLED, context.getConfigurationLevel()).getValueAsBooleanOrDefault(false); } String getSystemSecretKey() { return configurationManager.getForSystem(STRIPE_SECRET_KEY).getRequiredValue(); } Optional<Boolean> processWebhookEvent(String body, String signature) { try { com.stripe.model.Event event = Webhook.constructEvent(body, signature, getWebhookSignatureKey()); if("account.application.deauthorized".equals(event.getType()) && event.getLivemode() != null && event.getLivemode() == environment.acceptsProfiles(Profiles.of("dev", "test", "demo"))) { return Optional.of(revokeToken(event.getAccount())); } return Optional.of(true); } catch (Exception e) { log.error("got exception while handling stripe webhook", e); return Optional.empty(); } } private boolean revokeToken(String accountId) { String key = ConfigurationKeys.STRIPE_CONNECTED_ID.getValue(); Optional<Integer> optional = configurationRepository.findOrganizationIdByKeyAndValue(key, accountId); if(optional.isPresent()) { Integer organizationId = optional.get(); log.warn("revoking access token {} for organization {}", accountId, organizationId); configurationManager.deleteOrganizationLevelByKey(key, organizationId, UserManager.ADMIN_USERNAME); return true; } return false; } /** * After client side integration with stripe widget, our server receives the stripeToken * StripeToken is a single-use token that allows our server to actually charge the credit card and * get money on our account. * <p> * as documented in https://stripe.com/docs/tutorials/charges * * @return * @throws StripeException */ Optional<Charge> chargeCreditCard(PaymentSpecification spec) throws StripeException { var chargeParams = createParams(spec, Map.of()); chargeParams.put("card", spec.getGatewayToken().getToken()); return charge(spec, chargeParams ); } protected Map<String, Object> createParams(PaymentSpecification spec, Map<String, String> baseMetadata) { int tickets = ticketRepository.countTicketsInReservation(spec.getReservationId()); Map<String, Object> chargeParams = new HashMap<>(); chargeParams.put("amount", spec.getPriceWithVAT()); FeeCalculator.getCalculator(spec.getEvent(), configurationManager, spec.getCurrencyCode()) .apply(tickets, (long) spec.getPriceWithVAT()) .filter(l -> l > 0) .ifPresent(fee -> chargeParams.put("application_fee_amount", fee)); chargeParams.put("currency", spec.getEvent().getCurrency()); chargeParams.put("description", String.format("%d ticket(s) for event %s", tickets, spec.getEvent().getDisplayName())); chargeParams.put("metadata", MetadataBuilder.buildMetadata(spec, baseMetadata)); return chargeParams; } protected Optional<Charge> charge(PaymentSpecification spec, Map<String, Object> chargeParams ) throws StripeException { Optional<RequestOptions> opt = options(spec.getEvent(), builder -> builder.setIdempotencyKey(spec.getReservationId())); if(opt.isEmpty()) { return Optional.empty(); } RequestOptions options = opt.get(); Charge charge = Charge.create(chargeParams, options); if(charge.getBalanceTransactionObject() == null) { try { charge.setBalanceTransactionObject(retrieveBalanceTransaction(charge.getBalanceTransaction(), options)); } catch(Exception e) { log.warn("can't retrieve balance transaction", e); } } return Optional.of(charge); } PaymentResult getToken(PaymentSpecification spec) { if(spec.getGatewayToken() != null && spec.getGatewayToken().getPaymentProvider() == PaymentProxy.STRIPE) { return PaymentResult.initialized(spec.getGatewayToken().getToken()); } return PaymentResult.failed(ErrorsCode.STEP_2_MISSING_STRIPE_TOKEN); } private BalanceTransaction retrieveBalanceTransaction(String balanceTransaction, RequestOptions options) throws StripeException { return BalanceTransaction.retrieve(balanceTransaction, options); } Optional<RequestOptions> options(Event event) { return options(event, UnaryOperator.identity()); } Optional<RequestOptions> options(Event event, UnaryOperator<RequestOptions.RequestOptionsBuilder> optionsBuilderConfigurer) { RequestOptions.RequestOptionsBuilder builder = optionsBuilderConfigurer.apply(RequestOptions.builder()); if(isConnectEnabled(new PaymentContext(event))) { return configurationManager.getFor(STRIPE_CONNECTED_ID, ConfigurationLevel.event(event)).getValue() .map(connectedId -> { //connected stripe account builder.setStripeAccount(connectedId); return builder.setApiKey(getSystemSecretKey()).build(); }); } return Optional.of(builder.setApiKey(getSecretKey(event)).build()); } Optional<String> getConnectedAccount(PaymentContext paymentContext) { if(isConnectEnabled(paymentContext)) { return configurationManager.getFor(STRIPE_CONNECTED_ID, paymentContext.getConfigurationLevel()).getValue(); } return Optional.empty(); } Optional<PaymentInformation> getInfo(Transaction transaction, Event event) { try { Optional<RequestOptions> requestOptionsOptional = options(event); if(requestOptionsOptional.isPresent()) { RequestOptions options = requestOptionsOptional.get(); Charge charge = Charge.retrieve(transaction.getTransactionId(), options); String paidAmount = MonetaryUtil.formatCents(charge.getAmount(), charge.getCurrency()); String refundedAmount = MonetaryUtil.formatCents(charge.getAmountRefunded(), charge.getCurrency()); List<BalanceTransaction.Fee> fees = retrieveBalanceTransaction(charge.getBalanceTransaction(), options).getFeeDetails(); return Optional.of(new PaymentInformation(paidAmount, refundedAmount, getFeeAmount(fees, "stripe_fee"), getFeeAmount(fees, "application_fee"))); } return Optional.empty(); } catch (StripeException e) { return Optional.empty(); } } static String getFeeAmount(List<BalanceTransaction.Fee> fees, String feeType) { return fees.stream() .filter(f -> f.getType().equals(feeType)) .findFirst() .map(BalanceTransaction.Fee::getAmount) .map(String::valueOf) .orElse(null); } // https://stripe.com/docs/api#create_refund boolean refund(Transaction transaction, Event event, Integer amountToRefund) { Optional<Integer> amount = Optional.ofNullable(amountToRefund); String chargeId = transaction.getTransactionId(); try { String amountOrFull = amount.map(p -> MonetaryUtil.formatCents(p, transaction.getCurrency())).orElse("full"); log.info("Stripe: trying to do a refund for payment {} with amount: {}", chargeId, amountOrFull); Map<String, Object> params = new HashMap<>(); params.put("charge", chargeId); amount.ifPresent(a -> params.put("amount", a)); if(transaction.getPlatformFee() > 0 && isConnectEnabled(new PaymentContext(event))) { params.put("refund_application_fee", true); } Optional<RequestOptions> requestOptionsOptional = options(event); if(requestOptionsOptional.isPresent()) { RequestOptions options = requestOptionsOptional.get(); Refund r = Refund.create(params, options); boolean pending = PENDING.equals(r.getStatus()); if(SUCCEEDED.equals(r.getStatus()) || pending) { log.info("Stripe: refund for payment {} {} for amount: {}", chargeId, pending ? "registered": "executed with success", amountOrFull); return true; } else { log.warn("Stripe: was not able to refund payment with id {}, returned status is not 'succeded' but {}", chargeId, r.getStatus()); return false; } } return false; } catch (StripeException e) { log.warn("Stripe: was not able to refund payment with id " + chargeId, e); return false; } } boolean accept(PaymentMethod paymentMethod, PaymentContext context, EnumSet<ConfigurationKeys> additionalKeys, Predicate<Map<ConfigurationKeys, ConfigurationManager.MaybeConfiguration>> subValidator) { return paymentMethod == PaymentMethod.CREDIT_CARD && isActive(context, additionalKeys, subValidator); } boolean isActive(PaymentContext context, EnumSet<ConfigurationKeys> additionalKeys, Predicate<Map<ConfigurationKeys, ConfigurationManager.MaybeConfiguration>> subValidator) { var optionsToLoad = EnumSet.copyOf(additionalKeys); optionsToLoad.addAll(EnumSet.of(STRIPE_CC_ENABLED, PLATFORM_MODE_ENABLED, STRIPE_CONNECTED_ID)); var configuration = configurationManager.getFor(optionsToLoad, context.getConfigurationLevel()); return configuration.get(STRIPE_CC_ENABLED).getValueAsBooleanOrDefault(false) && (!configuration.get(PLATFORM_MODE_ENABLED).getValueAsBooleanOrDefault(false) || context.getConfigurationLevel().getPathLevel() == ConfigurationPathLevel.SYSTEM || configuration.get(STRIPE_CONNECTED_ID).isPresent()) && subValidator.test(configuration); } String handleException(StripeException exc) { return findExceptionHandler(exc).handle(exc); } private StripeExceptionHandler findExceptionHandler(StripeException exc) { final Optional<StripeExceptionHandler> eh = Optional.ofNullable(handlers.get(exc.getClass())); if(eh.isEmpty()) { log.warn("cannot find an ExceptionHandler for {}. Falling back to the default one.", exc.getClass()); } return eh.orElseGet(() -> handlers.get(StripeException.class)); } /* exception handlers... */ /** * This handler simply returns the message code from stripe. * There is no need in writing something in the log. * @param e the exception * @return the code */ private String handleCardException(StripeException e) { CardException ce = (CardException)e; return "error.STEP2_STRIPE_" + ce.getCode(); } /** * handles invalid request exception using the error.STEP2_STRIPE_invalid_ prefix for the message. * @param e the exception * @return message code */ private String handleInvalidRequestException(StripeException e) { InvalidRequestException ire = (InvalidRequestException)e; return "error.STEP2_STRIPE_invalid_" + ire.getParam(); } /** * Logs the failure and report the failure to the admin (to be done) * @param e the exception * @return error.STEP2_STRIPE_abort */ private String handleAuthenticationException(StripeException e) { log.error("an AuthenticationException has occurred. Please fix configuration!!", e); return "error.STEP2_STRIPE_abort"; } /** * Logs the failure and report the failure to the admin (to be done) * @param e * @return */ private String handleApiConnectionException(StripeException e) { log.error("unable to connect to the Stripe API", e); return "error.STEP2_STRIPE_abort"; } /** * Logs the failure and report the failure to the admin (to be done) * @param e * @return */ private String handleGenericException(StripeException e) { log.error("unexpected error during transaction", e); return StripeCreditCardManager.STRIPE_UNEXPECTED; } @FunctionalInterface private interface StripeExceptionHandler { String handle(StripeException exc); } }