From fc3341e244a893daaa230756d40c9ae1685e1e61 Mon Sep 17 00:00:00 2001 From: Celestino Bellone <3385346+cbellone@users.noreply.github.com> Date: Wed, 21 Dec 2022 15:00:47 +0100 Subject: [PATCH 1/4] create reproducer for #1159 --- .../StripePaymentWebhookController.java | 20 +- src/main/java/alfio/util/HttpUtils.java | 1 + .../java/alfio/BaseTestConfiguration.java | 19 ++ .../reservation/BaseReservationFlowTest.java | 123 +++---- .../reservation/ReservationFlowContext.java | 15 +- .../StripeReservationFlowIntegrationTest.java | 306 ++++++++++++++++++ .../StripeWebhookPaymentManagerTest.java | 2 +- .../stripe-success-valid-v2022-11-15.json | 74 +++++ .../stripe-success-valid.json | 2 +- 9 files changed, 497 insertions(+), 65 deletions(-) create mode 100644 src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java create mode 100644 src/test/resources/transaction-json/stripe-success-valid-v2022-11-15.json diff --git a/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java b/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java index 636ed650b3..1cda626e6d 100644 --- a/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java +++ b/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java @@ -21,7 +21,9 @@ import alfio.util.RequestUtils; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -30,6 +32,8 @@ import javax.servlet.http.HttpServletRequest; import java.util.Map; +import static alfio.util.HttpUtils.APPLICATION_JSON_UTF8; + @RestController @Log4j2 @AllArgsConstructor @@ -40,17 +44,25 @@ public class StripePaymentWebhookController { @PostMapping("/api/payment/webhook/stripe/payment") public ResponseEntity receivePaymentConfirmation(@RequestHeader(value = "Stripe-Signature") String stripeSignature, HttpServletRequest request) { + var httpHeaders = new HttpHeaders(); + httpHeaders.add(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8); return RequestUtils.readRequest(request) .map(content -> { var result = ticketReservationManager.processTransactionWebhook(content, stripeSignature, PaymentProxy.STRIPE, Map.of()); if(result.isSuccessful()) { - return ResponseEntity.ok("OK"); + return ResponseEntity.status(HttpStatus.OK) + .headers(httpHeaders) + .body("OK"); } else if(result.isError()) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result.getReason()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .headers(httpHeaders) + .body(result.getReason()); } - return ResponseEntity.ok(result.getReason()); + return ResponseEntity.status(HttpStatus.OK) + .headers(httpHeaders) + .body(result.getReason()); }) - .orElseGet(() -> ResponseEntity.badRequest().body("NOK")); + .orElseGet(() -> ResponseEntity.badRequest().headers(httpHeaders).body("Malformed request.")); } } diff --git a/src/main/java/alfio/util/HttpUtils.java b/src/main/java/alfio/util/HttpUtils.java index 5a033dced4..79b2158e58 100644 --- a/src/main/java/alfio/util/HttpUtils.java +++ b/src/main/java/alfio/util/HttpUtils.java @@ -35,6 +35,7 @@ private HttpUtils() { public static final String CONTENT_TYPE = "Content-Type"; public static final String APPLICATION_JSON = "application/json"; + public static final String APPLICATION_JSON_UTF8 = "application/json;charset=UTF-8"; public static final String APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded"; public static final String MULTIPART_FORM_DATA = "multipart/form-data"; public static final String AUTHORIZATION = "Authorization"; diff --git a/src/test/java/alfio/BaseTestConfiguration.java b/src/test/java/alfio/BaseTestConfiguration.java index 08438192a5..b964dd662b 100644 --- a/src/test/java/alfio/BaseTestConfiguration.java +++ b/src/test/java/alfio/BaseTestConfiguration.java @@ -24,15 +24,22 @@ import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; +import com.stripe.Stripe; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; +import org.springframework.boot.context.event.ApplicationPreparedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; import org.springframework.core.io.ByteArrayResource; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.PostgreSQLContainer; +import javax.annotation.PostConstruct; import javax.sql.DataSource; import java.io.ByteArrayOutputStream; import java.io.PrintWriter; @@ -116,4 +123,16 @@ public ExternalConfiguration externalConfiguration() { public ClockProvider clockProvider() { return FIXED_TIME_CLOCK; } + + @PostConstruct + @Profile("!travis") + public void initStripeMock() { + GenericContainer stripeMock = new GenericContainer<>("stripe/stripe-mock:latest") + .withExposedPorts(12111, 12112); + stripeMock.start(); + var httpPort = stripeMock.getMappedPort(12111); + Stripe.overrideApiBase("http://localhost:" + httpPort); + Stripe.overrideUploadBase("http://localhost:" + httpPort); + Stripe.enableTelemetry = false; + } } diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java index 6a4489594e..8bf3a4755c 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/BaseReservationFlowTest.java @@ -853,67 +853,13 @@ protected void testBasicFlow(Supplier contextSupplier) t assertTrue(owner.isEmpty()); } - var paymentForm = new PaymentForm(); - var handleResError = reservationApiV2Controller.confirmOverview(reservationId, "en", paymentForm, new BeanPropertyBindingResult(paymentForm, "paymentForm"), - new MockHttpServletRequest(), context.getPublicUser()); - assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, handleResError.getStatusCode()); - - - paymentForm.setPrivacyPolicyAccepted(true); - paymentForm.setTermAndConditionsAccepted(true); - paymentForm.setPaymentProxy(PaymentProxy.OFFLINE); - paymentForm.setSelectedPaymentMethod(PaymentMethod.BANK_TRANSFER); - - // bank transfer does not have a transaction, it's created on confirmOverview call - var tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, "BANK_TRANSFER"); - assertEquals(HttpStatus.NOT_FOUND, tStatus.getStatusCode()); - // int promoCodeId = promoCodeDiscountRepository.findPromoCodeInEventOrOrganization(context.event.getId(), PROMO_CODE).orElseThrow().getId(); - var promoCodeUsage = promoCodeRequestManager.retrieveDetailedUsage(promoCodeId, context.event.getId()); - assertTrue(promoCodeUsage.isEmpty()); - - var handleRes = reservationApiV2Controller.confirmOverview(reservationId, "en", paymentForm, new BeanPropertyBindingResult(paymentForm, "paymentForm"), - new MockHttpServletRequest(), context.getPublicUser()); - - assertEquals(HttpStatus.OK, handleRes.getStatusCode()); - - checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.OFFLINE_PAYMENT, context); - - tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, "BANK_TRANSFER"); - assertEquals(HttpStatus.OK, tStatus.getStatusCode()); - assertNotNull(tStatus.getBody()); - assertFalse(tStatus.getBody().isSuccess()); - - reservation = reservationApiV2Controller.getReservationInfo(reservationId, context.getPublicUser()).getBody(); - assertNotNull(reservation); - checkOrderSummary(reservation); - - //clear the extension_log table so that we can check the expectation - cleanupExtensionLog(); - - validatePayment(context.event.getShortName(), reservationId, context); - - extLogs = extensionLogRepository.getPage(null, null, null, 100, 0); - - boolean online = containsOnlineTickets(context, reservationId); - assertEventLogged(extLogs, RESERVATION_CONFIRMED, online ? 12 : 10); - assertEventLogged(extLogs, CONFIRMATION_MAIL_CUSTOM_TEXT, online ? 12 : 10); - assertEventLogged(extLogs, TICKET_ASSIGNED, online ? 12 : 10); - if(online) { - assertEventLogged(extLogs, CUSTOM_ONLINE_JOIN_URL, 12); - } - assertEventLogged(extLogs, TICKET_ASSIGNED_GENERATE_METADATA, online ? 12 : 10); - assertEventLogged(extLogs, TICKET_MAIL_CUSTOM_TEXT, online ? 12 : 10); - + // initialize and confirm payment + performAndValidatePayment(context, reservationId, promoCodeId, this::cleanupExtensionLog); checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.COMPLETE, context); - tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, "BANK_TRANSFER"); - assertEquals(HttpStatus.OK, tStatus.getStatusCode()); - assertNotNull(tStatus.getBody()); - assertTrue(tStatus.getBody().isSuccess()); - reservation = reservationApiV2Controller.getReservationInfo(reservationId, context.getPublicUser()).getBody(); assertNotNull(reservation); var orderSummary = reservation.getOrderSummary(); @@ -1245,6 +1191,65 @@ protected void testBasicFlow(Supplier contextSupplier) t } + protected void performAndValidatePayment(ReservationFlowContext context, + String reservationId, + int promoCodeId, + Runnable cleanupExtensionLog) { + ReservationInfo reservation; + var paymentForm = new PaymentForm(); + var handleResError = reservationApiV2Controller.confirmOverview(reservationId, "en", paymentForm, new BeanPropertyBindingResult(paymentForm, "paymentForm"), + new MockHttpServletRequest(), context.getPublicUser()); + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, handleResError.getStatusCode()); + + + paymentForm.setPrivacyPolicyAccepted(true); + paymentForm.setTermAndConditionsAccepted(true); + paymentForm.setPaymentProxy(PaymentProxy.OFFLINE); + paymentForm.setSelectedPaymentMethod(PaymentMethod.BANK_TRANSFER); + + // bank transfer does not have a transaction, it's created on confirmOverview call + var tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, "BANK_TRANSFER"); + assertEquals(HttpStatus.NOT_FOUND, tStatus.getStatusCode()); + // + var promoCodeUsage = promoCodeRequestManager.retrieveDetailedUsage(promoCodeId, context.event.getId()); + assertTrue(promoCodeUsage.isEmpty()); + + var handleRes = reservationApiV2Controller.confirmOverview(reservationId, "en", paymentForm, new BeanPropertyBindingResult(paymentForm, "paymentForm"), + new MockHttpServletRequest(), context.getPublicUser()); + + assertEquals(HttpStatus.OK, handleRes.getStatusCode()); + + checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.OFFLINE_PAYMENT, context); + + tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, "BANK_TRANSFER"); + assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + assertNotNull(tStatus.getBody()); + assertFalse(tStatus.getBody().isSuccess()); + + reservation = reservationApiV2Controller.getReservationInfo(reservationId, context.getPublicUser()).getBody(); + assertNotNull(reservation); + checkOrderSummary(reservation); + cleanupExtensionLog.run(); + validatePayment(context.event.getShortName(), reservationId, context); + + var extLogs = extensionLogRepository.getPage(null, null, null, 100, 0); + + boolean online = containsOnlineTickets(context, reservationId); + assertEventLogged(extLogs, RESERVATION_CONFIRMED, online ? 12 : 10); + assertEventLogged(extLogs, CONFIRMATION_MAIL_CUSTOM_TEXT, online ? 12 : 10); + assertEventLogged(extLogs, TICKET_ASSIGNED, online ? 12 : 10); + if(online) { + assertEventLogged(extLogs, CUSTOM_ONLINE_JOIN_URL, 12); + } + assertEventLogged(extLogs, TICKET_ASSIGNED_GENERATE_METADATA, online ? 12 : 10); + assertEventLogged(extLogs, TICKET_MAIL_CUSTOM_TEXT, online ? 12 : 10); + + tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, "BANK_TRANSFER"); + assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + assertNotNull(tStatus.getBody()); + assertTrue(tStatus.getBody().isSuccess()); + } + protected void checkDiscountUsage(String reservationId, int promoCodeId, ReservationFlowContext context) { var promoCodeUsage = promoCodeRequestManager.retrieveDetailedUsage(promoCodeId, context.event.getId()); assertTrue(promoCodeUsage.isEmpty()); @@ -1407,7 +1412,11 @@ private void assertEventLogged(List extLog, ExtensionEvent event, assertTrue(extLog.stream().anyMatch(l -> l.getDescription().equals(event.name()))); } - private void checkStatus(String reservationId, + protected void assertEventLogged(List extLog, ExtensionEvent event) { + assertTrue(extLog.stream().anyMatch(l -> l.getDescription().equals(event.name()))); + } + + protected final void checkStatus(String reservationId, HttpStatus expectedHttpStatus, Boolean validated, TicketReservation.TicketReservationStatus reservationStatus, diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowContext.java b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowContext.java index 09356672a8..711b836cae 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowContext.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowContext.java @@ -22,6 +22,7 @@ import java.security.Principal; import java.util.List; +import java.util.Map; import java.util.UUID; class ReservationFlowContext { @@ -34,16 +35,21 @@ class ReservationFlowContext { final boolean checkInStationsEnabled; final boolean applyDiscount; private final Authentication authentication; + private final Map additionalParams; ReservationFlowContext(Event event, String userId) { - this(event, userId, null, null, null, null, true, false); + this(event, userId, null, null, null, null, true, false, Map.of()); } ReservationFlowContext(Event event, String userId, UUID subscriptionId, String subscriptionPin) { - this(event, userId, subscriptionId, subscriptionPin, null, null, true, false); + this(event, userId, subscriptionId, subscriptionPin, null, null, true, false, Map.of()); } ReservationFlowContext(Event event, String userId, UUID subscriptionId, String subscriptionPin, String publicUsername, Integer publicUserId, boolean checkInStationsEnabled, boolean applyDiscount) { + this(event, userId, subscriptionId, subscriptionPin, publicUsername, publicUserId, checkInStationsEnabled, applyDiscount, Map.of()); + } + + ReservationFlowContext(Event event, String userId, UUID subscriptionId, String subscriptionPin, String publicUsername, Integer publicUserId, boolean checkInStationsEnabled, boolean applyDiscount, Map additionalParams) { this.event = event; this.userId = userId; this.subscriptionId = subscriptionId; @@ -57,6 +63,7 @@ class ReservationFlowContext { } this.checkInStationsEnabled = checkInStationsEnabled; this.applyDiscount = applyDiscount; + this.additionalParams = additionalParams; } Principal getPublicUser() { @@ -66,4 +73,8 @@ Principal getPublicUser() { Authentication getPublicAuthentication() { return authentication; } + + Map getAdditionalParams() { + return additionalParams; + } } diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java new file mode 100644 index 0000000000..4028b5e9f2 --- /dev/null +++ b/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java @@ -0,0 +1,306 @@ +/** + * 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 . + */ +package alfio.controller.api.v2.user.reservation; + +import alfio.TestConfiguration; +import alfio.config.DataSourceConfiguration; +import alfio.config.Initializer; +import alfio.controller.IndexController; +import alfio.controller.api.ControllerConfiguration; +import alfio.controller.api.admin.AdditionalServiceApiController; +import alfio.controller.api.admin.CheckInApiController; +import alfio.controller.api.admin.EventApiController; +import alfio.controller.api.admin.UsersApiController; +import alfio.controller.api.v1.AttendeeApiController; +import alfio.controller.api.v2.InfoApiController; +import alfio.controller.api.v2.TranslationsApiController; +import alfio.controller.api.v2.model.ReservationInfo; +import alfio.controller.api.v2.user.EventApiV2Controller; +import alfio.controller.api.v2.user.ReservationApiV2Controller; +import alfio.controller.api.v2.user.TicketApiV2Controller; +import alfio.controller.form.PaymentForm; +import alfio.controller.payment.api.stripe.StripePaymentWebhookController; +import alfio.extension.ExtensionService; +import alfio.manager.*; +import alfio.manager.user.UserManager; +import alfio.model.Event; +import alfio.model.TicketCategory; +import alfio.model.TicketReservation; +import alfio.model.metadata.AlfioMetadata; +import alfio.model.modification.DateTimeModification; +import alfio.model.modification.TicketCategoryModification; +import alfio.model.system.ConfigurationKeys; +import alfio.model.transaction.PaymentMethod; +import alfio.model.transaction.PaymentProxy; +import alfio.repository.*; +import alfio.repository.audit.ScanAuditRepository; +import alfio.repository.system.ConfigurationRepository; +import alfio.repository.user.OrganizationRepository; +import alfio.repository.user.UserRepository; +import alfio.util.BaseIntegrationTest; +import alfio.util.ClockProvider; +import com.stripe.net.Webhook; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.validation.BeanPropertyBindingResult; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static alfio.manager.support.extension.ExtensionEvent.*; +import static alfio.manager.support.extension.ExtensionEvent.TICKET_MAIL_CUSTOM_TEXT; +import static alfio.test.util.IntegrationTestUtil.*; +import static alfio.util.HttpUtils.APPLICATION_JSON; +import static alfio.util.HttpUtils.APPLICATION_JSON_UTF8; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Profile("!travis") +@ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) +@ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) +@Transactional +class StripeReservationFlowIntegrationTest extends BaseReservationFlowTest { + + private static final String WEBHOOK_SECRET = "WEBHOOK_SECRET"; + private static final String PAYLOAD_FILENAME = "payloadFilename"; + private final OrganizationRepository organizationRepository; + private final UserManager userManager; + private final StripePaymentWebhookController stripePaymentWebhookController; + + @Autowired + public StripeReservationFlowIntegrationTest(OrganizationRepository organizationRepository, + EventManager eventManager, + EventRepository eventRepository, + UserManager userManager, + ClockProvider clockProvider, + ConfigurationRepository configurationRepository, + EventStatisticsManager eventStatisticsManager, + TicketCategoryRepository ticketCategoryRepository, + TicketReservationRepository ticketReservationRepository, + EventApiController eventApiController, + TicketRepository ticketRepository, + TicketFieldRepository ticketFieldRepository, + AdditionalServiceApiController additionalServiceApiController, + SpecialPriceTokenGenerator specialPriceTokenGenerator, + SpecialPriceRepository specialPriceRepository, + CheckInApiController checkInApiController, + AttendeeApiController attendeeApiController, + UsersApiController usersApiController, + ScanAuditRepository scanAuditRepository, + AuditingRepository auditingRepository, + AdminReservationManager adminReservationManager, + TicketReservationManager ticketReservationManager, + InfoApiController infoApiController, + TranslationsApiController translationsApiController, + EventApiV2Controller eventApiV2Controller, + ReservationApiV2Controller reservationApiV2Controller, + TicketApiV2Controller ticketApiV2Controller, + IndexController indexController, + NamedParameterJdbcTemplate jdbcTemplate, + ExtensionLogRepository extensionLogRepository, + ExtensionService extensionService, + PollRepository pollRepository, + NotificationManager notificationManager, + UserRepository userRepository, + OrganizationDeleter organizationDeleter, + PromoCodeDiscountRepository promoCodeDiscountRepository, + PromoCodeRequestManager promoCodeRequestManager, + StripePaymentWebhookController stripePaymentWebhookController) { + super(configurationRepository, + eventManager, + eventRepository, + eventStatisticsManager, + ticketCategoryRepository, + ticketReservationRepository, + eventApiController, + ticketRepository, + ticketFieldRepository, + additionalServiceApiController, + specialPriceTokenGenerator, + specialPriceRepository, + checkInApiController, + attendeeApiController, + usersApiController, + scanAuditRepository, + auditingRepository, + adminReservationManager, + ticketReservationManager, + infoApiController, + translationsApiController, + eventApiV2Controller, + reservationApiV2Controller, + ticketApiV2Controller, + indexController, + jdbcTemplate, + extensionLogRepository, + extensionService, + pollRepository, + clockProvider, + notificationManager, + userRepository, + organizationDeleter, + promoCodeDiscountRepository, + promoCodeRequestManager); + this.organizationRepository = organizationRepository; + this.userManager = userManager; + this.stripePaymentWebhookController = stripePaymentWebhookController; + } + + @BeforeEach + void init() { + configurationRepository.insert(ConfigurationKeys.STRIPE_ENABLE_SCA.name(), "true", ""); + configurationRepository.insert(ConfigurationKeys.STRIPE_PUBLIC_KEY.name(), "pk_test_123", ""); + configurationRepository.insert(ConfigurationKeys.STRIPE_SECRET_KEY.name(), "sk_test_123", ""); + configurationRepository.insert(ConfigurationKeys.STRIPE_WEBHOOK_PAYMENT_KEY.name(), WEBHOOK_SECRET, ""); + } + + private ReservationFlowContext createContext(String payloadFilename) { + List categories = Arrays.asList( + new TicketCategoryModification(null, "default", TicketCategory.TicketAccessType.INHERIT, AVAILABLE_SEATS, + new DateTimeModification(LocalDate.now(clockProvider.getClock()).minusDays(1), LocalTime.now(clockProvider.getClock())), + new DateTimeModification(LocalDate.now(clockProvider.getClock()).plusDays(1), LocalTime.now(clockProvider.getClock())), + DESCRIPTION, BigDecimal.TEN, false, "", false, null, null, null, null, null, 0, null, null, AlfioMetadata.empty()), + new TicketCategoryModification(null, "hidden", TicketCategory.TicketAccessType.INHERIT, 2, + new DateTimeModification(LocalDate.now(clockProvider.getClock()).minusDays(1), LocalTime.now(clockProvider.getClock())), + new DateTimeModification(LocalDate.now(clockProvider.getClock()).plusDays(1), LocalTime.now(clockProvider.getClock())), + DESCRIPTION, BigDecimal.ONE, true, "", true, URL_CODE_HIDDEN, null, null, null, null, 0, null, null, AlfioMetadata.empty()) + ); + Pair eventAndUser = initEvent(categories, organizationRepository, userManager, eventManager, eventRepository, null, Event.EventFormat.IN_PERSON); + return new ReservationFlowContext(eventAndUser.getLeft(), eventAndUser.getRight() + "_owner", null, null, null, null, true, false, Map.of(PAYLOAD_FILENAME, payloadFilename)); + } + + @ParameterizedTest + @ValueSource(strings = { + "/transaction-json/stripe-success-valid.json", + "/transaction-json/stripe-success-valid-v2022-11-15.json" + }) + void payWithStripe(String payloadFilename) throws Exception { + super.testBasicFlow(() -> createContext(payloadFilename)); + } + + @Override + protected void performAndValidatePayment(ReservationFlowContext context, + String reservationId, + int promoCodeId, + Runnable cleanupExtensionLog) { + ReservationInfo reservation; + var paymentForm = new PaymentForm(); + + paymentForm.setPrivacyPolicyAccepted(true); + paymentForm.setTermAndConditionsAccepted(true); + paymentForm.setPaymentProxy(PaymentProxy.STRIPE); + paymentForm.setSelectedPaymentMethod(PaymentMethod.CREDIT_CARD); + + var tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, PaymentMethod.CREDIT_CARD.name()); + assertEquals(HttpStatus.NOT_FOUND, tStatus.getStatusCode()); + + // init payment + var initPaymentRes = reservationApiV2Controller.initTransaction(reservationId, PaymentMethod.CREDIT_CARD.name(), new LinkedMultiValueMap<>()); + assertEquals(HttpStatus.OK, initPaymentRes.getStatusCode()); + + tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, PaymentMethod.CREDIT_CARD.name()); + assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + + // + var promoCodeUsage = promoCodeRequestManager.retrieveDetailedUsage(promoCodeId, context.event.getId()); + assertTrue(promoCodeUsage.isEmpty()); + + var handleRes = reservationApiV2Controller.confirmOverview(reservationId, "en", paymentForm, new BeanPropertyBindingResult(paymentForm, "paymentForm"), + new MockHttpServletRequest(), context.getPublicUser()); + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, handleRes.getStatusCode()); + + cleanupExtensionLog.run(); + processWebHook(context.getAdditionalParams().get(PAYLOAD_FILENAME), reservationId); + + checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.COMPLETE, context); + + tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, PaymentMethod.CREDIT_CARD.name()); + assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + assertNotNull(tStatus.getBody()); + assertTrue(tStatus.getBody().isSuccess()); + + reservation = reservationApiV2Controller.getReservationInfo(reservationId, context.getPublicUser()).getBody(); + assertNotNull(reservation); + checkOrderSummary(reservation); + + var extLogs = extensionLogRepository.getPage(null, null, null, 100, 0); + + assertEventLogged(extLogs, RESERVATION_CONFIRMED); + assertEventLogged(extLogs, CONFIRMATION_MAIL_CUSTOM_TEXT); + assertEventLogged(extLogs, TICKET_ASSIGNED); + assertEventLogged(extLogs, TICKET_ASSIGNED_GENERATE_METADATA); + assertEventLogged(extLogs, TICKET_MAIL_CUSTOM_TEXT); + + tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, PaymentMethod.CREDIT_CARD.name()); + assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + assertNotNull(tStatus.getBody()); + assertTrue(tStatus.getBody().isSuccess()); + } + + @Override + protected void checkOrderSummary(ReservationInfo reservation) { + var orderSummary = reservation.getOrderSummary(); + assertFalse(orderSummary.isNotYetPaid()); + assertEquals("10.00", orderSummary.getTotalPrice()); + assertEquals("0.10", orderSummary.getTotalVAT()); + assertEquals("1.00", orderSummary.getVatPercentage()); + } + + private void processWebHook(String filename, String reservationId) { + try { + var resource = getClass().getResource(filename); + assertNotNull(resource); + var timestamp = String.valueOf(Webhook.Util.getTimeNow()); + var payload = Files.readString(Path.of(resource.toURI())).replaceAll("RESERVATION_ID", reservationId); + var signedHeader = "t=" + timestamp + ",v1=" +Webhook.Util.computeHmacSha256(WEBHOOK_SECRET, timestamp + "." + payload); + var httpRequest = new MockHttpServletRequest(); + httpRequest.setContent(payload.getBytes()); + httpRequest.setContentType(APPLICATION_JSON); + var response = stripePaymentWebhookController.receivePaymentConfirmation(signedHeader, httpRequest); + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(APPLICATION_JSON_UTF8, response.getHeaders().getContentType().toString()); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } +} diff --git a/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java b/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java index a8263950ce..22eb9b97f1 100644 --- a/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java +++ b/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java @@ -50,7 +50,7 @@ class StripeWebhookPaymentManagerTest { - private static final String RESERVATION_ID = "abcdefg"; + private static final String RESERVATION_ID = "RESERVATION_ID"; private static final String PAYMENT_ID = "PAYMENT_ID"; private static final int EVENT_ID = 11; private static final int TRANSACTION_ID = 22; diff --git a/src/test/resources/transaction-json/stripe-success-valid-v2022-11-15.json b/src/test/resources/transaction-json/stripe-success-valid-v2022-11-15.json new file mode 100644 index 0000000000..bf5de35919 --- /dev/null +++ b/src/test/resources/transaction-json/stripe-success-valid-v2022-11-15.json @@ -0,0 +1,74 @@ +{ + "id": "evt_ID", + "object": "event", + "api_version": "2022-11-15", + "created": 1671384724, + "data": { + "object": { + "id": "pi_ID", + "object": "payment_intent", + "amount": 1000, + "amount_capturable": 0, + "amount_details": { + "tip": { + } + }, + "amount_received": 1000, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_ID_secret_ID", + "confirmation_method": "automatic", + "created": 1671384722, + "currency": "chf", + "customer": null, + "description": "1 ticket(s) for event Event Name", + "invoice": null, + "last_payment_error": null, + "latest_charge": "ch_ID", + "livemode": false, + "metadata": { + "fullName": "Test McTest", + "alfioBaseUrl": "https://alf.io", + "billingAddress": "Test McTest,Bahnhofstrasse 1,8000 Zürich,Switzerland", + "reservationId": "RESERVATION_ID", + "email": "noreply@example.org" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_ID", + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": "pi_ID-src_ID" + }, + "type": "payment_intent.succeeded" +} \ No newline at end of file diff --git a/src/test/resources/transaction-json/stripe-success-valid.json b/src/test/resources/transaction-json/stripe-success-valid.json index 362d15299a..4bec6efc22 100644 --- a/src/test/resources/transaction-json/stripe-success-valid.json +++ b/src/test/resources/transaction-json/stripe-success-valid.json @@ -145,7 +145,7 @@ "fullName": "Test McTest", "alfioBaseUrl": "https://alf.io", "billingAddress": "Test McTest,Bahnhofstrasse 1,8000 Zürich,Switzerland", - "reservationId": "abcdefg", + "reservationId": "RESERVATION_ID", "email": "noreply@example.org" }, "next_action": null, From 2dd5b5be67b45f8e07fa5af697684d0d2e956119 Mon Sep 17 00:00:00 2001 From: Celestino Bellone <3385346+cbellone@users.noreply.github.com> Date: Wed, 21 Dec 2022 17:29:20 +0100 Subject: [PATCH 2/4] update Stripe API to 2022-11-15, handle events coming from older API version --- build.gradle | 2 +- .../manager/payment/BaseStripeManager.java | 17 +-- .../payment/StripeCreditCardManager.java | 9 +- .../payment/StripeWebhookPaymentManager.java | 114 ++++++++++++++++-- .../java/alfio/BaseTestConfiguration.java | 1 - .../StripeReservationFlowIntegrationTest.java | 1 - .../StripeWebhookPaymentManagerTest.java | 36 +++--- 7 files changed, 137 insertions(+), 43 deletions(-) diff --git a/build.gradle b/build.gradle index 5ccbc00bc2..c40a8bcdf8 100644 --- a/build.gradle +++ b/build.gradle @@ -136,7 +136,7 @@ dependencies { implementation "org.apache.logging.log4j:log4j-slf4j-impl:2.17.1" /**/ - implementation "com.stripe:stripe-java:20.50.0" + implementation "com.stripe:stripe-java:22.3.0" implementation 'com.paypal.sdk:checkout-sdk:1.0.5' implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.1', { diff --git a/src/main/java/alfio/manager/payment/BaseStripeManager.java b/src/main/java/alfio/manager/payment/BaseStripeManager.java index 004bc7a2ff..d548858807 100644 --- a/src/main/java/alfio/manager/payment/BaseStripeManager.java +++ b/src/main/java/alfio/manager/payment/BaseStripeManager.java @@ -110,8 +110,7 @@ Optional 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"))) { + && Boolean.TRUE.equals(event.getLivemode()) == environment.acceptsProfiles(Profiles.of("dev", "test", "demo"))) { return Optional.of(revokeToken(event.getAccount())); } return Optional.of(true); @@ -194,10 +193,14 @@ PaymentResult getToken(PaymentSpecification spec) { return PaymentResult.failed(ErrorsCode.STEP_2_MISSING_STRIPE_TOKEN); } - private BalanceTransaction retrieveBalanceTransaction(String balanceTransaction, RequestOptions options) throws StripeException { + BalanceTransaction retrieveBalanceTransaction(String balanceTransaction, RequestOptions options) throws StripeException { return BalanceTransaction.retrieve(balanceTransaction, options); } + Charge retrieveCharge(String chargeId, RequestOptions requestOptions) throws StripeException { + return Charge.retrieve(chargeId, requestOptions); + } + Optional options(PurchaseContext purchaseContext) { return options(purchaseContext, UnaryOperator.identity()); } @@ -227,10 +230,10 @@ Optional getInfo(Transaction transaction, PurchaseContext pu Optional requestOptionsOptional = options(purchaseContext); if(requestOptionsOptional.isPresent()) { RequestOptions options = requestOptionsOptional.get(); - Charge charge = Charge.retrieve(transaction.getTransactionId(), options); + Charge charge = retrieveCharge(transaction.getTransactionId(), options); String paidAmount = MonetaryUtil.formatCents(charge.getAmount(), charge.getCurrency()); String refundedAmount = MonetaryUtil.formatCents(charge.getAmountRefunded(), charge.getCurrency()); - List fees = retrieveBalanceTransaction(charge.getBalanceTransaction(), options).getFeeDetails(); + List fees = retrieveBalanceTransaction(charge.getBalanceTransaction(), options).getFeeDetails(); return Optional.of(new PaymentInformation(paidAmount, refundedAmount, getFeeAmount(fees, "stripe_fee"), getFeeAmount(fees, "application_fee"))); } return Optional.empty(); @@ -239,11 +242,11 @@ Optional getInfo(Transaction transaction, PurchaseContext pu } } - static String getFeeAmount(List fees, String feeType) { + static String getFeeAmount(List fees, String feeType) { return fees.stream() .filter(f -> f.getType().equals(feeType)) .findFirst() - .map(BalanceTransaction.Fee::getAmount) + .map(BalanceTransaction.FeeDetail::getAmount) .map(String::valueOf) .orElse(null); } diff --git a/src/main/java/alfio/manager/payment/StripeCreditCardManager.java b/src/main/java/alfio/manager/payment/StripeCreditCardManager.java index c58752354f..0d45e21e40 100644 --- a/src/main/java/alfio/manager/payment/StripeCreditCardManager.java +++ b/src/main/java/alfio/manager/payment/StripeCreditCardManager.java @@ -52,7 +52,7 @@ public class StripeCreditCardManager implements PaymentProvider, ClientServerTok public static final String STRIPE_UNEXPECTED = "error.STEP2_STRIPE_unexpected"; private static final String STRIPE_MANAGER = StripeCreditCardManager.class.getName(); - public static final EnumSet OPTIONS_TO_LOAD = EnumSet.of(STRIPE_ENABLE_SCA, STRIPE_SECRET_KEY, STRIPE_PUBLIC_KEY); + protected static final EnumSet OPTIONS_TO_LOAD = EnumSet.of(STRIPE_ENABLE_SCA, STRIPE_SECRET_KEY, STRIPE_PUBLIC_KEY); private final TransactionRepository transactionRepository; private final BaseStripeManager baseStripeManager; @@ -151,7 +151,7 @@ public PaymentResult doPayment( PaymentSpecification spec ) { return optionalCharge.map(charge -> { log.info("transaction {} paid: {}", spec.getReservationId(), charge.getPaid()); Pair fees = Optional.ofNullable(charge.getBalanceTransactionObject()).map( bt -> { - List feeDetails = bt.getFeeDetails(); + List feeDetails = bt.getFeeDetails(); return Pair.of(Optional.ofNullable( BaseStripeManager.getFeeAmount(feeDetails, "application_fee")).map(Long::parseLong).orElse(0L), Optional.ofNullable( BaseStripeManager.getFeeAmount(feeDetails, "stripe_fee")).map(Long::parseLong).orElse(0L)); }).orElse(null); @@ -162,10 +162,9 @@ public PaymentResult doPayment( PaymentSpecification spec ) { fees != null ? fees.getLeft() : 0L, fees != null ? fees.getRight() : 0L, Transaction.Status.COMPLETE, Map.of(STRIPE_MANAGER_TYPE_KEY, STRIPE_MANAGER)); return PaymentResult.successful(charge.getId()); }).orElseGet(() -> PaymentResult.failed("error.STEP2_UNABLE_TO_TRANSITION")); + } catch (StripeException e) { + return PaymentResult.failed(baseStripeManager.handleException(e)); } catch (Exception e) { - if(e instanceof StripeException) { - return PaymentResult.failed( baseStripeManager.handleException((StripeException)e)); - } throw new IllegalStateException(e); } } diff --git a/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java b/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java index d790457ef4..4dc7240881 100644 --- a/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java +++ b/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java @@ -40,9 +40,13 @@ import com.stripe.model.Charge; import com.stripe.model.PaymentIntent; import com.stripe.model.StripeObject; +import com.stripe.net.HttpHeaders; +import com.stripe.net.RequestOptions; +import com.stripe.net.StripeResponse; import com.stripe.net.Webhook; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.Validate; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -55,6 +59,7 @@ import static alfio.model.TicketReservation.TicketReservationStatus.EXTERNAL_PROCESSING_PAYMENT; import static alfio.model.TicketReservation.TicketReservationStatus.WAITING_EXTERNAL_CONFIRMATION; import static alfio.model.system.ConfigurationKeys.*; +import static java.util.Objects.requireNonNull; @Log4j2 @Component @@ -78,6 +83,7 @@ public class StripeWebhookPaymentManager implements PaymentProvider, RefundReque private final List interestingEventTypes = List.of(PAYMENT_INTENT_SUCCEEDED, PAYMENT_INTENT_PAYMENT_FAILED, PAYMENT_INTENT_CREATED); private final Set cancellableStatuses = Set.of(REQUIRES_PAYMENT_METHOD, "requires_confirmation", "requires_action"); + @Autowired public StripeWebhookPaymentManager(ConfigurationManager configurationManager, TicketRepository ticketRepository, TransactionRepository transactionRepository, @@ -87,12 +93,28 @@ public StripeWebhookPaymentManager(ConfigurationManager configurationManager, AuditingRepository auditingRepository, Environment environment, ClockProvider clockProvider) { + this(configurationManager, + transactionRepository, + ticketReservationRepository, + eventRepository, + auditingRepository, + clockProvider, + new BaseStripeManager(configurationManager, configurationRepository, ticketRepository, environment)); + } + + StripeWebhookPaymentManager(ConfigurationManager configurationManager, + TransactionRepository transactionRepository, + TicketReservationRepository ticketReservationRepository, + EventRepository eventRepository, + AuditingRepository auditingRepository, + ClockProvider clockProvider, + BaseStripeManager baseStripeManager) { this.configurationManager = configurationManager; this.transactionRepository = transactionRepository; this.ticketReservationRepository = ticketReservationRepository; this.eventRepository = eventRepository; this.auditingRepository = auditingRepository; - this.baseStripeManager = new BaseStripeManager(configurationManager, configurationRepository, ticketRepository, environment); + this.baseStripeManager = baseStripeManager; this.clockProvider = clockProvider; } @@ -175,7 +197,7 @@ private TransactionInitializationToken buildTokenFromTransaction(Transaction tra 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()), purchaseContext); + processSuccessfulPaymentIntent(transaction, paymentIntent, ticketReservationRepository.findReservationById(transaction.getReservationId()), purchaseContext, requestOptions); return errorToken("Reservation status changed", true); } else if(!status.equals(REQUIRES_PAYMENT_METHOD)) { return errorToken("Payment in process", true); @@ -224,9 +246,11 @@ public Optional parseTransactionPayload(String body, var stripeEvent = Webhook.constructEvent(body, signature, getWebhookSignatureKey(paymentContext.getConfigurationLevel())); String eventType = stripeEvent.getType(); if(eventType.startsWith("charge.")) { - return deserializeObject(stripeEvent).map(obj -> new StripeChargeTransactionWebhookPayload(eventType, (Charge)obj)); + return deserializeObject(stripeEvent, body) + .map(obj -> new StripeChargeTransactionWebhookPayload(eventType, (Charge)obj)); } else if(eventType.startsWith("payment_intent.")) { - return deserializeObject(stripeEvent).map(obj -> new StripePaymentIntentWebhookPayload(eventType, (PaymentIntent)obj)); + return deserializeObject(stripeEvent, body) + .map(obj -> new StripePaymentIntentWebhookPayload(eventType, (PaymentIntent)obj)); } return Optional.empty(); } catch (Exception e) { @@ -235,7 +259,7 @@ public Optional parseTransactionPayload(String body, } } - private Optional deserializeObject(com.stripe.model.Event stripeEvent) { + private Optional deserializeObject(com.stripe.model.Event stripeEvent, String rawJson) { var dataObjectDeserializer = stripeEvent.getDataObjectDeserializer(); var cleanDeserialization = dataObjectDeserializer.getObject(); if(cleanDeserialization.isPresent()) { @@ -243,7 +267,17 @@ private Optional deserializeObject(com.stripe.model.Event stripeEv } log.warn("unable to deserialize payload. Expected version {}, actual {}, falling back to unsafe deserialization", Stripe.API_VERSION, stripeEvent.getApiVersion()); try { - return Optional.ofNullable(dataObjectDeserializer.deserializeUnsafe()); + return Optional.ofNullable(dataObjectDeserializer.deserializeUnsafe()) + .map(stripeObject -> { + // if we message we receive was built with an API version older than 2022-11-15 + // we need to save the raw JSON body to ensure we have all the information to parse the message + // see https://stripe.com/docs/upgrades#2022-11-15 and https://github.com/alfio-event/alf.io/issues/1159 + if (stripeObject.getLastResponse() == null && "2022-11-15".compareTo(stripeEvent.getApiVersion()) > 0) { + log.debug("API version requires raw JSON body. Forcing 'lastResponse' property"); + stripeObject.setLastResponse(new StripeResponse(200, HttpHeaders.of(Map.of()), rawJson)); + } + return stripeObject; + }); } catch(Exception e) { throw new IllegalArgumentException("Cannot deserialize webhook event.", e); } @@ -282,7 +316,7 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr return PaymentWebhookResult.processStarted(buildTokenFromTransaction(transaction, purchaseContext, false)); } case PAYMENT_INTENT_SUCCEEDED: { - return processSuccessfulPaymentIntent(transaction, paymentIntent, reservation, purchaseContext); + return processSuccessfulPaymentIntent(transaction, paymentIntent, reservation, purchaseContext, baseStripeManager.options(purchaseContext).orElseThrow()); } case PAYMENT_INTENT_PAYMENT_FAILED: { return processFailedPaymentIntent(transaction, reservation, purchaseContext); @@ -314,13 +348,16 @@ private PaymentWebhookResult processFailedPaymentIntent(Transaction transaction, return PaymentWebhookResult.failed("Charge has been reset by Stripe. This is usually caused by a rejection from the customer's bank"); } - private PaymentWebhookResult processSuccessfulPaymentIntent(Transaction transaction, PaymentIntent paymentIntent, TicketReservation reservation, PurchaseContext purchaseContext) { - var charge = paymentIntent.getCharges().getData().get(0); - var chargeId = charge.getId(); - long gtwFee = Optional.ofNullable(charge.getBalanceTransactionObject()).map(BalanceTransaction::getFee).orElse(0L); + private PaymentWebhookResult processSuccessfulPaymentIntent(Transaction transaction, + PaymentIntent paymentIntent, + TicketReservation reservation, + PurchaseContext purchaseContext, + RequestOptions requestOptions) throws StripeException { + var chargeAndFees = retrieveChargeIdAndFees(paymentIntent, requestOptions); + var chargeId = chargeAndFees.getChargeId(); transactionRepository.lockByIdForUpdate(transaction.getId());// this serializes int affectedRows = transactionRepository.updateIfStatus(transaction.getId(), chargeId, - transaction.getPaymentId(), purchaseContext.now(clockProvider), transaction.getPlatformFee(), gtwFee, + transaction.getPaymentId(), purchaseContext.now(clockProvider), transaction.getPlatformFee(), chargeAndFees.getFeesOrZero(), Transaction.Status.COMPLETE, Map.of(), Transaction.Status.PENDING); List> modifications = List.of(Map.of("paymentId", chargeId, "paymentMethod", "stripe")); if(affectedRows == 0) { @@ -337,6 +374,38 @@ private PaymentWebhookResult processSuccessfulPaymentIntent(Transaction transact return PaymentWebhookResult.successful(new StripeSCACreditCardToken(transaction.getPaymentId(), chargeId, null)); } + private ChargeIdAndFees retrieveChargeIdAndFees(PaymentIntent paymentIntent, RequestOptions requestOptions) throws StripeException { + String chargeId = paymentIntent.getLatestCharge(); + String balanceTransactionId = null; + long fees = 0L; + if (chargeId == null) { + // compatibility mode for payloads with API version up to 2022-08-01 + var jsonObject = paymentIntent.getRawJsonObject(); + // old structure is paymentIntent -> data -> object -> charges -> data[0] -> id + var chargesContainer = requireNonNull(jsonObject.getAsJsonObject("data") + .getAsJsonObject("object") + .getAsJsonObject("charges"), "data -> object -> charges is null!"); + var latestCharge = requireNonNull(chargesContainer.getAsJsonArray("data").get(0), "charges is empty!") + .getAsJsonObject(); + + chargeId = requireNonNull(latestCharge.get("id"), "charges array is empty!").getAsString(); + if (latestCharge.has("balance_transaction")) { + balanceTransactionId = latestCharge.get("balance_transaction").getAsString(); + } + } + + if (balanceTransactionId == null) { + var charge = baseStripeManager.retrieveCharge(chargeId, requestOptions); + balanceTransactionId = charge.getBalanceTransaction(); + } + + if (balanceTransactionId != null) { + fees = baseStripeManager.retrieveBalanceTransaction(balanceTransactionId, requestOptions).getFee(); + } + + return new ChargeIdAndFees(chargeId, fees); + } + @Override public boolean accept(PaymentMethod paymentMethod, PaymentContext context, TransactionRequest transactionRequest) { return baseStripeManager.accept(paymentMethod, context, OPTIONS_TO_LOAD, this::isConfigurationValid); @@ -426,7 +495,7 @@ public PaymentWebhookResult forceTransactionCheck(TicketReservation reservation, case "requires_confirmation": return PaymentWebhookResult.pending(); case "succeeded": - return processSuccessfulPaymentIntent(transaction, intent, reservation, purchaseContext); + return processSuccessfulPaymentIntent(transaction, intent, reservation, purchaseContext, options); case REQUIRES_PAYMENT_METHOD: //payment is failed. return processFailedPaymentIntent(transaction, reservation, purchaseContext); @@ -463,4 +532,23 @@ public Optional detectPaymentContext(String payload) { return Optional.empty(); } } + + private static class ChargeIdAndFees { + private final String chargeId; + private final Long fees; + + + private ChargeIdAndFees(String chargeId, Long fees) { + this.chargeId = chargeId; + this.fees = fees; + } + + public String getChargeId() { + return chargeId; + } + + public long getFeesOrZero() { + return fees != null ? fees : 0L; + } + } } diff --git a/src/test/java/alfio/BaseTestConfiguration.java b/src/test/java/alfio/BaseTestConfiguration.java index b964dd662b..20b9b6db6f 100644 --- a/src/test/java/alfio/BaseTestConfiguration.java +++ b/src/test/java/alfio/BaseTestConfiguration.java @@ -125,7 +125,6 @@ public ClockProvider clockProvider() { } @PostConstruct - @Profile("!travis") public void initStripeMock() { GenericContainer stripeMock = new GenericContainer<>("stripe/stripe-mock:latest") .withExposedPorts(12111, 12112); diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java index 4028b5e9f2..45405d3e0d 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/StripeReservationFlowIntegrationTest.java @@ -93,7 +93,6 @@ import static org.junit.jupiter.api.Assertions.*; @SpringBootTest -@Profile("!travis") @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) @Transactional diff --git a/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java b/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java index 22eb9b97f1..e7f50d6d4c 100644 --- a/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java +++ b/src/test/java/alfio/manager/payment/StripeWebhookPaymentManagerTest.java @@ -20,6 +20,7 @@ import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.system.ConfigurationManager.MaybeConfiguration; +import alfio.manager.testSupport.MaybeConfigurationBuilder; import alfio.model.Audit; import alfio.model.Event; import alfio.model.TicketReservation; @@ -32,6 +33,7 @@ import com.stripe.model.Charge; import com.stripe.model.ChargeCollection; import com.stripe.model.PaymentIntent; +import com.stripe.net.RequestOptions; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -67,10 +69,12 @@ class StripeWebhookPaymentManagerTest { private AuditingRepository auditingRepository; private Environment environment; private TicketReservation ticketReservation; + private BaseStripeManager baseStripeManager; + private static final String SK_LIVE = "sk_live_"; private static final MaybeConfiguration STRIPE_SECRET_KEY_CONF = new MaybeConfiguration(ConfigurationKeys.STRIPE_SECRET_KEY, - new ConfigurationKeyValuePathLevel(null, "sk_live_", null)); + new ConfigurationKeyValuePathLevel(null, SK_LIVE, null)); @BeforeEach void setup() { @@ -92,6 +96,9 @@ void setup() { ticketReservation = mock(TicketReservation.class); when(ticketReservation.getId()).thenReturn(RESERVATION_ID); when(eventRepository.findByReservationId(eq(RESERVATION_ID))).thenReturn(event); + baseStripeManager = mock(BaseStripeManager.class); + when(baseStripeManager.getSecretKey(any())).thenReturn(SK_LIVE); + when(baseStripeManager.options(any())).thenReturn(Optional.of(RequestOptions.builder().build())); stripeWebhookPaymentManager = new StripeWebhookPaymentManager(configurationManager, ticketRepository, transactionRepository, configurationRepository, ticketReservationRepository, eventRepository, auditingRepository, environment, TestUtil.clockProvider()); } @@ -105,26 +112,27 @@ void ignoreNotRelevantTypes() { } @Test - void transactionSucceeded() { + void transactionSucceeded() throws Exception { var paymentIntent = mock(PaymentIntent.class); var transactionWebhookPayload = mock(TransactionWebhookPayload.class); when(transactionWebhookPayload.getType()).thenReturn("payment_intent.succeeded"); when(transactionWebhookPayload.getPayload()).thenReturn(paymentIntent); when(paymentIntent.getMetadata()).thenReturn(Map.of(MetadataBuilder.RESERVATION_ID, RESERVATION_ID)); when(paymentIntent.getStatus()).thenReturn(BaseStripeManager.SUCCEEDED); - var chargeCollection = mock(ChargeCollection.class); - when(paymentIntent.getCharges()).thenReturn(chargeCollection); var charge = mock(Charge.class); - when(chargeCollection.getData()).thenReturn(List.of(charge)); + when(paymentIntent.getLatestCharge()).thenReturn(CHARGE_ID); + when(baseStripeManager.retrieveCharge(eq(CHARGE_ID), any())).thenReturn(charge); when(charge.getId()).thenReturn(CHARGE_ID); when(ticketReservationRepository.findOptionalReservationById(eq(RESERVATION_ID))).thenReturn(Optional.of(ticketReservation)); when(ticketReservation.getStatus()).thenReturn(TicketReservation.TicketReservationStatus.EXTERNAL_PROCESSING_PAYMENT); var paymentContext = mock(PaymentContext.class); when(paymentContext.getPurchaseContext()).thenReturn(event); when(configurationManager.getFor(eq(STRIPE_SECRET_KEY), any())).thenReturn(STRIPE_SECRET_KEY_CONF); + when(configurationManager.getFor(eq(PLATFORM_MODE_ENABLED), any())).thenReturn(MaybeConfigurationBuilder.missing(PLATFORM_MODE_ENABLED)); when(paymentIntent.getLivemode()).thenReturn(true); when(transactionRepository.updateIfStatus(eq(TRANSACTION_ID), eq(CHARGE_ID), eq(PAYMENT_ID), any(), eq(0L), eq(0L), eq(Transaction.Status.COMPLETE), eq(Map.of()), eq(Transaction.Status.PENDING))).thenReturn(1); - var paymentWebhookResult = stripeWebhookPaymentManager.processWebhook(transactionWebhookPayload, transaction, paymentContext); + var customWebHookPaymentManager = new StripeWebhookPaymentManager(configurationManager, transactionRepository, ticketReservationRepository, eventRepository, auditingRepository, TestUtil.clockProvider(), baseStripeManager); + var paymentWebhookResult = customWebHookPaymentManager.processWebhook(transactionWebhookPayload, transaction, paymentContext); assertEquals(PaymentWebhookResult.Type.SUCCESSFUL, paymentWebhookResult.getType()); verify(transactionRepository).updateIfStatus(eq(TRANSACTION_ID), eq(CHARGE_ID), eq(PAYMENT_ID), any(), eq(0L), eq(0L), eq(Transaction.Status.COMPLETE), eq(Map.of()), eq(Transaction.Status.PENDING)); Map changes = Map.of("paymentId", CHARGE_ID, "paymentMethod", "stripe"); @@ -132,26 +140,26 @@ void transactionSucceeded() { } @Test - void transactionAlreadyConfirmed() { + void transactionAlreadyConfirmed() throws Exception { var paymentIntent = mock(PaymentIntent.class); var transactionWebhookPayload = mock(TransactionWebhookPayload.class); when(transactionWebhookPayload.getType()).thenReturn("payment_intent.succeeded"); when(transactionWebhookPayload.getPayload()).thenReturn(paymentIntent); when(paymentIntent.getMetadata()).thenReturn(Map.of(MetadataBuilder.RESERVATION_ID, RESERVATION_ID)); when(paymentIntent.getStatus()).thenReturn(BaseStripeManager.SUCCEEDED); - var chargeCollection = mock(ChargeCollection.class); - when(paymentIntent.getCharges()).thenReturn(chargeCollection); var charge = mock(Charge.class); - when(chargeCollection.getData()).thenReturn(List.of(charge)); - when(charge.getId()).thenReturn(CHARGE_ID); + when(paymentIntent.getLatestCharge()).thenReturn(CHARGE_ID); + when(baseStripeManager.retrieveCharge(eq(CHARGE_ID), any())).thenReturn(charge); when(ticketReservationRepository.findOptionalReservationById(eq(RESERVATION_ID))).thenReturn(Optional.of(ticketReservation)); when(ticketReservation.getStatus()).thenReturn(TicketReservation.TicketReservationStatus.EXTERNAL_PROCESSING_PAYMENT); var paymentContext = mock(PaymentContext.class); when(paymentContext.getPurchaseContext()).thenReturn(event); when(configurationManager.getFor(eq(STRIPE_SECRET_KEY), any())).thenReturn(STRIPE_SECRET_KEY_CONF); + when(configurationManager.getFor(eq(PLATFORM_MODE_ENABLED), any())).thenReturn(MaybeConfigurationBuilder.missing(PLATFORM_MODE_ENABLED)); when(paymentIntent.getLivemode()).thenReturn(true); when(transactionRepository.updateIfStatus(eq(TRANSACTION_ID), eq(CHARGE_ID), eq(PAYMENT_ID), any(), eq(0L), eq(0L), eq(Transaction.Status.COMPLETE), eq(Map.of()), eq(Transaction.Status.PENDING))).thenReturn(0); - var paymentWebhookResult = stripeWebhookPaymentManager.processWebhook(transactionWebhookPayload, transaction, paymentContext); + var customWebHookPaymentManager = new StripeWebhookPaymentManager(configurationManager, transactionRepository, ticketReservationRepository, eventRepository, auditingRepository, TestUtil.clockProvider(), baseStripeManager); + var paymentWebhookResult = customWebHookPaymentManager.processWebhook(transactionWebhookPayload, transaction, paymentContext); assertEquals(PaymentWebhookResult.Type.SUCCESSFUL, paymentWebhookResult.getType()); verify(transactionRepository).updateIfStatus(eq(TRANSACTION_ID), eq(CHARGE_ID), eq(PAYMENT_ID), any(), eq(0L), eq(0L), eq(Transaction.Status.COMPLETE), eq(Map.of()), eq(Transaction.Status.PENDING)); Map changes = Map.of("paymentId", CHARGE_ID, "paymentMethod", "stripe"); @@ -191,10 +199,8 @@ void doNotAcceptTestEventsOnLiveEnv() { when(transactionWebhookPayload.getPayload()).thenReturn(paymentIntent); when(paymentIntent.getMetadata()).thenReturn(Map.of(MetadataBuilder.RESERVATION_ID, RESERVATION_ID)); when(paymentIntent.getStatus()).thenReturn(BaseStripeManager.SUCCEEDED); - var chargeCollection = mock(ChargeCollection.class); - when(paymentIntent.getCharges()).thenReturn(chargeCollection); var charge = mock(Charge.class); - when(chargeCollection.getData()).thenReturn(List.of(charge)); + when(paymentIntent.getLatestChargeObject()).thenReturn(charge); when(charge.getId()).thenReturn(CHARGE_ID); when(ticketReservationRepository.findOptionalReservationById(eq(RESERVATION_ID))).thenReturn(Optional.of(ticketReservation)); when(ticketReservation.getStatus()).thenReturn(TicketReservation.TicketReservationStatus.EXTERNAL_PROCESSING_PAYMENT); From 3a315f7c92a5d9997bfbe519b34b23af3f299e2c Mon Sep 17 00:00:00 2001 From: Celestino Bellone <3385346+cbellone@users.noreply.github.com> Date: Wed, 21 Dec 2022 18:16:09 +0100 Subject: [PATCH 3/4] remove unused import --- .../payment/api/stripe/StripePaymentWebhookController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java b/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java index 1cda626e6d..99d3cebd54 100644 --- a/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java +++ b/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java @@ -23,7 +23,6 @@ import lombok.extern.log4j.Log4j2; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; From 3ac5ecafb664bef1de119b76accc963719e9e20f Mon Sep 17 00:00:00 2001 From: Celestino Bellone <3385346+cbellone@users.noreply.github.com> Date: Wed, 21 Dec 2022 18:38:42 +0100 Subject: [PATCH 4/4] fix typo --- .../java/alfio/manager/payment/StripeWebhookPaymentManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java b/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java index 4dc7240881..a0523e7142 100644 --- a/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java +++ b/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java @@ -269,7 +269,7 @@ private Optional deserializeObject(com.stripe.model.Event stripeEv try { return Optional.ofNullable(dataObjectDeserializer.deserializeUnsafe()) .map(stripeObject -> { - // if we message we receive was built with an API version older than 2022-11-15 + // if the message we received was built with an API version older than 2022-11-15 // we need to save the raw JSON body to ensure we have all the information to parse the message // see https://stripe.com/docs/upgrades#2022-11-15 and https://github.com/alfio-event/alf.io/issues/1159 if (stripeObject.getLastResponse() == null && "2022-11-15".compareTo(stripeEvent.getApiVersion()) > 0) {