Skip to content

Commit

Permalink
use request id to ensure idempotency of the payments
Browse files Browse the repository at this point in the history
  • Loading branch information
cbellone committed Jan 22, 2020
1 parent e018467 commit e1e1cd0
Show file tree
Hide file tree
Showing 10 changed files with 164 additions and 40 deletions.
32 changes: 22 additions & 10 deletions src/main/java/alfio/manager/TicketReservationManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -542,11 +542,18 @@ public boolean cancelPendingPayment(String reservationId, Event event) {
log.warn("Trying to cancel a non-pending transaction for reservation {}", reservationId);
return false;
}
paymentManager.lookupByTransactionAndCapabilities(optionalTransaction.get(), List.of(ServerInitiatedTransaction.class))
.ifPresent(provider -> ((ServerInitiatedTransaction)provider).discardTransaction(optionalTransaction.get(), event));
reTransitionToPending(reservationId);
auditingRepository.insert(reservationId, null, event.getId(), RESET_PAYMENT, new Date(), RESERVATION, reservationId);
return true;
Transaction transaction = optionalTransaction.get();
boolean remoteDeleteResult = paymentManager.lookupByTransactionAndCapabilities(transaction, List.of(ServerInitiatedTransaction.class))
.map(provider -> ((ServerInitiatedTransaction)provider).discardTransaction(optionalTransaction.get(), event))
.orElse(true);

if(remoteDeleteResult) {
reTransitionToPending(reservationId);
auditingRepository.insert(reservationId, null, event.getId(), RESET_PAYMENT, new Date(), RESERVATION, reservationId);
return true;
}
log.warn("Cannot delete payment with ID {} for reservation {}", transaction.getPaymentId(), reservationId);
return false;
}

private void transitionToComplete(PaymentSpecification spec, TotalPrice reservationCost, PaymentProxy paymentProxy) {
Expand Down Expand Up @@ -939,11 +946,16 @@ public static boolean isValidPaymentMethod(PaymentManager.PaymentMethodDTO payme
&& (!paymentMethodDTO.getPaymentProxy().equals(PaymentProxy.OFFLINE) || hasValidOfflinePaymentWaitingPeriod(new PaymentContext(event), configurationManager));
}

private void reTransitionToPending(String reservationId) {
private void reTransitionToPending(String reservationId, boolean deleteTransactions) {
int updatedReservation = ticketReservationRepository.updateReservationStatus(reservationId, TicketReservationStatus.PENDING.toString());
Validate.isTrue(updatedReservation == 1, "expected exactly one updated reservation, got "+updatedReservation);
// delete all pending transactions, if any
transactionRepository.deleteForReservationsWithStatus(List.of(reservationId), Transaction.Status.PENDING);
if(deleteTransactions) {
// delete all pending transactions, if any
transactionRepository.deleteForReservationsWithStatus(List.of(reservationId), Transaction.Status.PENDING);
}
}
private void reTransitionToPending(String reservationId) {
reTransitionToPending(reservationId, true);
}

//check internal consistency between the 3 values
Expand Down Expand Up @@ -1943,7 +1955,7 @@ public PaymentWebhookResult processTransactionWebhook(String body, String signat
} else if(DateUtils.addMinutes(expiration, -slackTime).before(now)) {
ticketReservationRepository.updateValidity(reservation.getId(), DateUtils.addMinutes(now, slackTime));
}
reTransitionToPending(reservation.getId());
reTransitionToPending(reservation.getId(), false);
sendTransactionFailedEmail(event, reservation, paymentMethod, paymentWebhookResult, false);
break;
}
Expand Down Expand Up @@ -2003,7 +2015,7 @@ public Optional<TransactionInitializationToken> initTransaction(Event event, Str
if(!acquireGroupMembers(reservationId, event)) {
groupManager.deleteWhitelistedTicketsForReservation(reservationId);
var errorMessage = messageSource.getMessage("error.STEP2_WHITELIST", null, LocaleUtil.forLanguageTag(reservation.getUserLanguage()));
return Optional.of(provider.errorToken(errorMessage));
return Optional.of(provider.errorToken(errorMessage, false));
}
var transactionToken = provider.initTransaction(paymentSpecification, params);
if(transitionToExternalProcessingPayment(reservation)) {
Expand Down
14 changes: 9 additions & 5 deletions src/main/java/alfio/manager/payment/BaseStripeManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

import java.util.*;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;

import static alfio.model.system.ConfigurationKeys.*;

Expand Down Expand Up @@ -144,7 +145,7 @@ private boolean revokeToken(String accountId) {
Optional<Charge> chargeCreditCard(PaymentSpecification spec) throws StripeException {
var chargeParams = createParams(spec, Map.of());
chargeParams.put("card", spec.getGatewayToken().getToken());
return charge( spec.getEvent(), chargeParams );
return charge(spec, chargeParams );
}

protected Map<String, Object> createParams(PaymentSpecification spec, Map<String, String> baseMetadata) {
Expand All @@ -163,8 +164,8 @@ protected Map<String, Object> createParams(PaymentSpecification spec, Map<String
return chargeParams;
}

protected Optional<Charge> charge( Event event, Map<String, Object> chargeParams ) throws StripeException {
Optional<RequestOptions> opt = options(event);
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();
}
Expand All @@ -191,9 +192,12 @@ private BalanceTransaction retrieveBalanceTransaction(String balanceTransaction,
return BalanceTransaction.retrieve(balanceTransaction, options);
}


Optional<RequestOptions> options(Event event) {
RequestOptions.RequestOptionsBuilder builder = RequestOptions.builder();
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 -> {
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/alfio/manager/payment/PayPalManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ private List<Transaction> buildPaymentDetails(Event event, OrderSummary orderSum

private String createCheckoutRequest(PaymentSpecification spec) throws Exception {
APIContext apiContext = getApiContext(spec.getEvent());
apiContext.setRequestId(spec.getReservationId());

Optional<String> experienceProfileId = getOrCreateWebProfile(spec.getEvent(), spec.getLocale(), apiContext);

Expand Down Expand Up @@ -220,7 +221,9 @@ private Pair<String, String> commitPayment(String reservationId, PayPalToken pay
paymentExecute.setPayerId(payPalToken.getPayerId());
Payment result;
try {
result = payment.execute(getApiContext(event), paymentExecute);
APIContext apiContext = getApiContext(event);
apiContext.setRequestId(reservationId);
result = payment.execute(apiContext, paymentExecute);
} catch (PayPalRESTException e) {
mappedException(e).ifPresent(message -> {
throw new HandledPayPalErrorException(message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import alfio.model.Audit;
import alfio.model.Event;
import alfio.model.PaymentInformation;
import alfio.model.TicketReservation;
import alfio.model.system.ConfigurationKeys;
import alfio.model.transaction.*;
import alfio.model.transaction.capabilities.*;
Expand Down Expand Up @@ -68,6 +69,7 @@ public class StripeWebhookPaymentManager implements PaymentProvider, RefundReque
private final EventRepository eventRepository;
private final AuditingRepository auditingRepository;
private final List<String> interestingEventTypes = List.of(PAYMENT_INTENT_SUCCEEDED, PAYMENT_INTENT_PAYMENT_FAILED, PAYMENT_INTENT_CREATED);
private final Set<String> cancellableStatuses = Set.of("requires_payment_method", "requires_confirmation", "requires_action");

public StripeWebhookPaymentManager(ConfigurationManager configurationManager,
TicketRepository ticketRepository,
Expand All @@ -89,12 +91,18 @@ public StripeWebhookPaymentManager(ConfigurationManager configurationManager,
public TransactionInitializationToken initTransaction(PaymentSpecification paymentSpecification, Map<String, List<String>> params) {
var reservationId = paymentSpecification.getReservationId();
return transactionRepository.loadOptionalByReservationId(reservationId)
.map(this::buildTokenFromTransaction)
.map(transaction -> {
if(transaction.getStatus() == Transaction.Status.PENDING) {
return buildTokenFromTransaction(transaction, paymentSpecification.getEvent(), true);
} else {
return errorToken("Reload reservation", true);
}
})
.orElseGet(() -> createNewToken(paymentSpecification));
}

@Override
public TransactionInitializationToken errorToken(String errorMessage) {
public TransactionInitializationToken errorToken(String errorMessage, boolean reservationStatusChanged) {
return new TransactionInitializationToken() {
@Override
public String getClientSecret() {
Expand All @@ -120,6 +128,11 @@ public PaymentProxy getPaymentProvider() {
public String getErrorMessage() {
return errorMessage;
}

@Override
public boolean isReservationStatusChanged() {
return reservationStatusChanged;
}
};
}

Expand All @@ -129,16 +142,39 @@ public boolean discardTransaction(Transaction transaction, Event event) {
try {
var requestOptions = baseStripeManager.options(event).orElseThrow();
var paymentIntent = PaymentIntent.retrieve(paymentId, requestOptions);
paymentIntent.cancel(requestOptions);
if(cancellableStatuses.contains(paymentIntent.getStatus())) {
paymentIntent.cancel(requestOptions);
return true;
}
log.warn("An attempt to cancel a non-cancellable Payment Intent has been detected for reservation ID {}, PaymentIntent ID {}", transaction.getReservationId(), transaction.getPaymentId());
} catch (StripeException e) {
log.warn("got Stripe error while trying to cancel transaction", e);
}
return false;
}

private StripeSCACreditCardToken buildTokenFromTransaction(Transaction transaction) {
private TransactionInitializationToken buildTokenFromTransaction(Transaction transaction, Event event, boolean performRemoteVerification) {
String clientSecret = Optional.ofNullable(transaction.getMetadata()).map(m -> m.get(CLIENT_SECRET_METADATA)).orElse(null);
String chargeId = transaction.getStatus() == Transaction.Status.COMPLETE ? transaction.getTransactionId() : null;

if(performRemoteVerification && transaction.getStatus() == Transaction.Status.PENDING) {
// try to retrieve PaymentIntent
try {
var requestOptions = baseStripeManager.options(event).orElseThrow();
var paymentIntent = PaymentIntent.retrieve(transaction.getPaymentId(), requestOptions);
var status = paymentIntent.getStatus();
if(status.equals("succeeded")) {
// the existing PaymentIntent succeeded, so we can confirm the reservation
log.info("marking reservation {} as paid, because PaymentIntent reports success", transaction.getReservationId());
processSuccessfulPaymentIntent(transaction, paymentIntent, ticketReservationRepository.findReservationById(transaction.getReservationId()), event);
return errorToken("Reservation status changed", true);
} else if(!status.equals("requires_payment_method")) {
return errorToken("Payment in process", true);
}
} catch (StripeException e) {
throw new IllegalStateException(e);
}
}
return new StripeSCACreditCardToken(transaction.getPaymentId(), chargeId, clientSecret);
}

Expand All @@ -149,7 +185,8 @@ private StripeSCACreditCardToken createNewToken(PaymentSpecification paymentSpec
var paymentIntentParams = baseStripeManager.createParams(paymentSpecification, baseMetadata);
paymentIntentParams.put("payment_method_types", List.of("card"));
try {
var intent = PaymentIntent.create(paymentIntentParams, baseStripeManager.options(paymentSpecification.getEvent()).orElseThrow());
var options = baseStripeManager.options(paymentSpecification.getEvent(), builder -> builder.setIdempotencyKey(paymentSpecification.getReservationId())).orElseThrow();
var intent = PaymentIntent.create(paymentIntentParams, options);
var clientSecret = intent.getClientSecret();
long platformFee = paymentIntentParams.containsKey("application_fee") ? (long) paymentIntentParams.get("application_fee") : 0L;
PaymentManagerUtils.invalidateExistingTransactions(paymentSpecification.getReservationId(), transactionRepository);
Expand Down Expand Up @@ -230,15 +267,10 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr
var event = eventRepository.findByReservationId(reservation.getId());
switch(payload.getType()) {
case PAYMENT_INTENT_CREATED: {
return PaymentWebhookResult.processStarted(buildTokenFromTransaction(transaction));
return PaymentWebhookResult.processStarted(buildTokenFromTransaction(transaction, event, false));
}
case PAYMENT_INTENT_SUCCEEDED: {
var charge = paymentIntent.getCharges().getData().get(0);
var chargeId = charge.getId();
long gtwFee = Optional.ofNullable(charge.getBalanceTransactionObject()).map(BalanceTransaction::getFee).orElse(0L);
transactionRepository.update(transaction.getId(), chargeId, transaction.getPaymentId(), ZonedDateTime.now(), transaction.getPlatformFee(), gtwFee, Transaction.Status.COMPLETE, Map.of());
List<Map<String, Object>> modifications = List.of(Map.of("paymentId", chargeId, "paymentMethod", "stripe"));
auditingRepository.insert(reservation.getId(), null, event.getId(), Audit.EventType.PAYMENT_CONFIRMED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications);
String chargeId = processSuccessfulPaymentIntent(transaction, paymentIntent, reservation, event);
return PaymentWebhookResult.successful(new StripeSCACreditCardToken(transaction.getPaymentId(), chargeId, null));
}
case PAYMENT_INTENT_PAYMENT_FAILED: {
Expand All @@ -249,7 +281,7 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr

List<Map<String, Object>> modifications = List.of(Map.of("paymentId", transaction.getPaymentId(), "paymentMethod", "stripe"));
auditingRepository.insert(reservation.getId(), null, event.getId(), Audit.EventType.PAYMENT_FAILED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications);
transactionRepository.updateStatusForReservation(reservation.getId(), Transaction.Status.FAILED);
//transactionRepository.updateStatusForReservation(reservation.getId(), Transaction.Status.FAILED);
return PaymentWebhookResult.failed("Charge has been reset by Stripe. This is usually caused by a rejection from the customer's bank");
}
}
Expand All @@ -261,6 +293,29 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr
return PaymentWebhookResult.notRelevant("event is not relevant");
}

private String processSuccessfulPaymentIntent(Transaction transaction, PaymentIntent paymentIntent, TicketReservation reservation, Event event) {
var charge = paymentIntent.getCharges().getData().get(0);
var chargeId = charge.getId();
long gtwFee = Optional.ofNullable(charge.getBalanceTransactionObject()).map(BalanceTransaction::getFee).orElse(0L);
transactionRepository.lockByIdForUpdate(transaction.getId());// this serializes
int affectedRows = transactionRepository.updateIfStatus(transaction.getId(), chargeId,
transaction.getPaymentId(), ZonedDateTime.now(), transaction.getPlatformFee(), gtwFee,
Transaction.Status.COMPLETE, Map.of(), Transaction.Status.PENDING);
List<Map<String, Object>> modifications = List.of(Map.of("paymentId", chargeId, "paymentMethod", "stripe"));
if(affectedRows == 0) {
// the transaction was already confirmed from someone else.
// We can safely return the chargeId, but we write in the auditing that we skipped the confirmation
auditingRepository.insert(reservation.getId(), null,
event.getId(), Audit.EventType.PAYMENT_ALREADY_CONFIRMED,
new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications);
return chargeId;
}
auditingRepository.insert(reservation.getId(), null,
event.getId(), Audit.EventType.PAYMENT_CONFIRMED,
new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications);
return chargeId;
}

@Override
public boolean accept(PaymentMethod paymentMethod, PaymentContext context) {
return baseStripeManager.accept(paymentMethod, context,
Expand Down
1 change: 1 addition & 0 deletions src/main/java/alfio/model/Audit.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public enum EventType {
UPDATE_EVENT,
CANCEL_TICKET,
PAYMENT_CONFIRMED,
PAYMENT_ALREADY_CONFIRMED,
REFUND,
CHECK_IN,
MANUAL_CHECK_IN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@

/**
* Token used for transactions initiated on the backend side and finalized by the frontend side.
* A typical example of this is Stripe SCE
* A typical example of this is Stripe SCA
*/
public interface TransactionInitializationToken extends PaymentToken {
String getClientSecret();

default String getErrorMessage() {
return null;
}

/**
* Override this method if you want to trigger a reload of the reservation page.
* @return true if the reservation should be reloaded, false otherwise
*/
default boolean isReservationStatusChanged() {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public interface ServerInitiatedTransaction extends Capability {

TransactionInitializationToken initTransaction(PaymentSpecification paymentSpecification, Map<String, List<String>> params);

TransactionInitializationToken errorToken(String errorMessage);
TransactionInitializationToken errorToken(String errorMessage, boolean reservationStatusChanged);

boolean discardTransaction(Transaction transaction, Event event);
}
Loading

0 comments on commit e1e1cd0

Please sign in to comment.