diff --git a/gradle.properties b/gradle.properties index 56ac121a1d..2b0ab593b2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,4 @@ targetCompatibility=11 systemProp.jdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2" # https://jitpack.io/#alfio-event/alf.io-public-frontend -> go to commit tab, set the version -alfioPublicFrontendVersion=f895ce2a0e \ No newline at end of file +alfioPublicFrontendVersion=5149b98fb9 \ No newline at end of file diff --git a/src/main/java/alfio/config/DataSourceConfiguration.java b/src/main/java/alfio/config/DataSourceConfiguration.java index 065f6eb40b..87f0223d79 100644 --- a/src/main/java/alfio/config/DataSourceConfiguration.java +++ b/src/main/java/alfio/config/DataSourceConfiguration.java @@ -22,10 +22,7 @@ import alfio.config.support.PlatformProvider; import alfio.extension.ExtensionService; import alfio.job.Jobs; -import alfio.job.executor.AssignTicketToSubscriberJobExecutor; -import alfio.job.executor.BillingDocumentJobExecutor; -import alfio.job.executor.ReservationJobExecutor; -import alfio.job.executor.RetryFailedExtensionJobExecutor; +import alfio.job.executor.*; import alfio.manager.*; import alfio.manager.i18n.MessageSourceManager; import alfio.manager.system.AdminJobManager; @@ -252,9 +249,10 @@ AdminJobManager adminJobManager(AdminJobQueueRepository adminJobQueueRepository, ReservationJobExecutor reservationJobExecutor, BillingDocumentJobExecutor billingDocumentJobExecutor, AssignTicketToSubscriberJobExecutor assignTicketToSubscriberJobExecutor, - RetryFailedExtensionJobExecutor retryFailedExtensionJobExecutor) { + RetryFailedExtensionJobExecutor retryFailedExtensionJobExecutor, + RetryFailedReservationConfirmationExecutor retryFailedReservationConfirmationExecutor) { return new AdminJobManager( - List.of(reservationJobExecutor, billingDocumentJobExecutor, assignTicketToSubscriberJobExecutor, retryFailedExtensionJobExecutor), + List.of(reservationJobExecutor, billingDocumentJobExecutor, assignTicketToSubscriberJobExecutor, retryFailedExtensionJobExecutor, retryFailedReservationConfirmationExecutor), adminJobQueueRepository, transactionManager, clockProvider); @@ -294,6 +292,11 @@ RetryFailedExtensionJobExecutor retryFailedExtensionJobExecutor(ExtensionService return new RetryFailedExtensionJobExecutor(extensionService); } + @Bean + RetryFailedReservationConfirmationExecutor retryFailedReservationConfirmationExecutor(ReservationFinalizer reservationFinalizer, Json json) { + return new RetryFailedReservationConfirmationExecutor(reservationFinalizer, json); + } + @Bean @Profile(Initializer.PROFILE_DEMO) DemoModeDataManager demoModeDataManager(UserRepository userRepository, diff --git a/src/main/java/alfio/controller/IndexController.java b/src/main/java/alfio/controller/IndexController.java index b44ede0a03..e20ae648eb 100644 --- a/src/main/java/alfio/controller/IndexController.java +++ b/src/main/java/alfio/controller/IndexController.java @@ -26,7 +26,9 @@ import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; +import alfio.model.TicketReservation.TicketReservationStatus; import alfio.model.system.ConfigurationKeys; +import alfio.model.transaction.PaymentProxy; import alfio.model.user.Role; import alfio.repository.*; import alfio.repository.user.OrganizationRepository; @@ -290,8 +292,12 @@ private static Element buildScripTag(String content, String type, String id, Str private static String reservationStatusToUrlMapping(TicketReservationStatusAndValidation status) { switch (status.getStatus()) { case PENDING: return Boolean.TRUE.equals(status.getValidated()) ? "overview" : "book"; - case COMPLETE: return "success"; - case OFFLINE_PAYMENT: return "waiting-payment"; + case COMPLETE: + case FINALIZING: + return "success"; + case OFFLINE_PAYMENT: + case OFFLINE_FINALIZING: + return "waiting-payment"; case DEFERRED_OFFLINE_PAYMENT: return "deferred-payment"; case EXTERNAL_PROCESSING_PAYMENT: case WAITING_EXTERNAL_CONFIRMATION: return "processing-payment"; diff --git a/src/main/java/alfio/controller/api/support/TicketHelper.java b/src/main/java/alfio/controller/api/support/TicketHelper.java index daa40220d9..023aacfca4 100644 --- a/src/main/java/alfio/controller/api/support/TicketHelper.java +++ b/src/main/java/alfio/controller/api/support/TicketHelper.java @@ -26,10 +26,7 @@ import alfio.model.user.Organization; import alfio.repository.*; import alfio.repository.user.OrganizationRepository; -import alfio.util.EventUtil; -import alfio.util.LocaleUtil; -import alfio.util.TemplateManager; -import alfio.util.Validator; +import alfio.util.*; import alfio.util.Validator.AdvancedTicketAssignmentValidator; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -217,7 +214,7 @@ private void updateTicketOwner(UpdateTicketOwnerForm updateTicketOwner, Locale f private PartialTicketTextGenerator getOwnerChangeTextBuilder(Locale ticketLanguage, Ticket t, Event event) { Organization organization = organizationRepository.getById(event.getOrganizationId()); - String ticketUrl = ticketReservationManager.ticketUpdateUrl(event, t.getUuid()); + String ticketUrl = ReservationUtil.ticketUpdateUrl(event, t, configurationManager); return TemplateProcessor.buildEmailForOwnerChange(event, t, organization, ticketUrl, templateManager, ticketLanguage); } diff --git a/src/main/java/alfio/controller/api/v1/admin/ReservationApiV1Controller.java b/src/main/java/alfio/controller/api/v1/admin/ReservationApiV1Controller.java index e1d6a12d9d..f968e8131c 100644 --- a/src/main/java/alfio/controller/api/v1/admin/ReservationApiV1Controller.java +++ b/src/main/java/alfio/controller/api/v1/admin/ReservationApiV1Controller.java @@ -128,7 +128,7 @@ private CreationResponse postCreate(ReservationAPICreationRequest creationReques ticketReservationManager.setReservationOwner(id, user.getUsername(), user.getEmail(), user.getFirstName(), user.getLastName(), locale.getLanguage()); } if(creationRequest.getReservationConfiguration() != null) { - ticketReservationManager.setReservationMetadata(id, new ReservationMetadata(creationRequest.getReservationConfiguration().isHideContactData())); + ticketReservationManager.setReservationMetadata(id, new ReservationMetadata(creationRequest.getReservationConfiguration().isHideContactData(), false, false)); } var subscriptionId = creationRequest instanceof TicketReservationCreationRequest ? ((TicketReservationCreationRequest) creationRequest).getSubscriptionId() : null; return CreationResponse.success(id, ticketReservationManager.reservationUrlForExternalClients(id, purchaseContext, locale.getLanguage(), user != null, subscriptionId)); diff --git a/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java b/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java index dbfa790764..b5a8ebf5b4 100644 --- a/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java +++ b/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java @@ -162,7 +162,7 @@ public ResponseEntity getReservationInfo(@PathVariable("reserva var additionalInfo = ticketReservationRepository.getAdditionalInfo(reservationId); - var shortReservationId = ticketReservationManager.getShortReservationID(purchaseContext, reservation); + var shortReservationId = configurationManager.getShortReservationID(purchaseContext, reservation); // diff --git a/src/main/java/alfio/controller/api/v2/user/TicketApiV2Controller.java b/src/main/java/alfio/controller/api/v2/user/TicketApiV2Controller.java index 82fb315f72..ad7d460e10 100644 --- a/src/main/java/alfio/controller/api/v2/user/TicketApiV2Controller.java +++ b/src/main/java/alfio/controller/api/v2/user/TicketApiV2Controller.java @@ -28,6 +28,7 @@ import alfio.manager.*; import alfio.manager.i18n.MessageSourceManager; import alfio.manager.support.response.ValidatedResponse; +import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.transaction.PaymentProxy; import alfio.model.user.Organization; @@ -77,6 +78,7 @@ public class TicketApiV2Controller { private final BookingInfoTicketLoader bookingInfoTicketLoader; private final TicketRepository ticketRepository; private final SubscriptionManager subscriptionManager; + private final ConfigurationManager configurationManager; @GetMapping(value = { @@ -119,7 +121,7 @@ public void generateTicketPdf(@PathVariable("eventName") String eventName, try (OutputStream os = response.getOutputStream()) { TicketCategory ticketCategory = ticketCategoryRepository.getByIdAndActive(ticket.getCategoryId(), event.getId()); Organization organization = organizationRepository.getById(event.getOrganizationId()); - String reservationID = ticketReservationManager.getShortReservationID(event, ticketReservation); + String reservationID = configurationManager.getShortReservationID(event, ticketReservation); var ticketWithMetadata = TicketWithMetadataAttributes.build(ticket, ticketRepository.getTicketMetadata(ticket.getId())); var locale = LocaleUtil.getTicketLanguage(ticket, LocaleUtil.forLanguageTag(ticketReservation.getUserLanguage(), event)); TemplateProcessor.renderPDFTicket( @@ -232,7 +234,7 @@ public ResponseEntity getTicketInfo(@PathVariable("eventName") Strin ticket.getUuid(), ticketCategory.getName(), ticketReservation.getFullName(), - ticketReservationManager.getShortReservationID(event, ticketReservation), + configurationManager.getShortReservationID(event, ticketReservation), deskPaymentRequired, event.getTimeZone(), DatesWithTimeZoneOffset.fromEvent(event), diff --git a/src/main/java/alfio/extension/support/SandboxNativeJavaMap.java b/src/main/java/alfio/extension/support/SandboxNativeJavaMap.java index bbfe318056..ed40095c2b 100644 --- a/src/main/java/alfio/extension/support/SandboxNativeJavaMap.java +++ b/src/main/java/alfio/extension/support/SandboxNativeJavaMap.java @@ -37,6 +37,11 @@ public Object get(String name, Scriptable start) { throw new OutOfBoundariesException("Out of boundaries class use."); } + if (map.get(name) == null) { + // prevent NPE on Rhino when map has an explicit null value for a given key + return null; + } + return super.get(name, start); } } diff --git a/src/main/java/alfio/job/executor/RetryFailedReservationConfirmationExecutor.java b/src/main/java/alfio/job/executor/RetryFailedReservationConfirmationExecutor.java new file mode 100644 index 0000000000..c2c72f8fbc --- /dev/null +++ b/src/main/java/alfio/job/executor/RetryFailedReservationConfirmationExecutor.java @@ -0,0 +1,51 @@ +/** + * 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.job.executor; + +import alfio.manager.ReservationFinalizer; +import alfio.manager.support.RetryFinalizeReservation; +import alfio.manager.system.AdminJobExecutor; +import alfio.model.system.AdminJobSchedule; +import alfio.util.Json; + +import java.util.EnumSet; +import java.util.Set; + +public class RetryFailedReservationConfirmationExecutor implements AdminJobExecutor { + + private final ReservationFinalizer reservationFinalizer; + private final Json json; + + public RetryFailedReservationConfirmationExecutor(ReservationFinalizer reservationFinalizer, + Json json) { + this.reservationFinalizer = reservationFinalizer; + this.json = json; + } + + @Override + public Set getJobNames() { + return EnumSet.of(JobName.RETRY_RESERVATION_CONFIRMATION); + } + + @Override + public String process(AdminJobSchedule schedule) { + var metadata = schedule.getMetadata(); + var retryFinalizeReservation = (String) metadata.get("payload"); + reservationFinalizer.retryFinalizeReservation(json.fromJsonString(retryFinalizeReservation, RetryFinalizeReservation.class)); + return null; + } +} diff --git a/src/main/java/alfio/manager/AdminReservationManager.java b/src/main/java/alfio/manager/AdminReservationManager.java index e806bb19d9..34fa095a6c 100644 --- a/src/main/java/alfio/manager/AdminReservationManager.java +++ b/src/main/java/alfio/manager/AdminReservationManager.java @@ -19,8 +19,9 @@ import alfio.controller.support.TemplateProcessor; import alfio.manager.i18n.MessageSourceManager; import alfio.manager.payment.PaymentSpecification; -import alfio.manager.support.IncompatibleStateException; import alfio.manager.support.DuplicateReferenceException; +import alfio.manager.support.IncompatibleStateException; +import alfio.manager.support.reservation.ReservationEmailContentHelper; import alfio.manager.system.ReservationPriceCalculator; import alfio.model.*; import alfio.model.PurchaseContext.PurchaseContextType; @@ -81,6 +82,7 @@ import static alfio.util.Wrappers.optionally; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; +import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNullElse; import static java.util.stream.Collectors.*; import static org.apache.commons.lang3.StringUtils.firstNonBlank; @@ -119,6 +121,7 @@ public class AdminReservationManager { private final BillingDocumentManager billingDocumentManager; private final ClockProvider clockProvider; private final SubscriptionRepository subscriptionRepository; + private final ReservationEmailContentHelper reservationEmailContentHelper; //the following methods have an explicit transaction handling, therefore the @Transactional annotation is not helpful here Result, PurchaseContext>> confirmReservation(PurchaseContextType purchaseContextType, @@ -129,25 +132,26 @@ Result, PurchaseContext>> confirmReservat UUID subscriptionId) { DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); TransactionTemplate template = new TransactionTemplate(transactionManager, definition); - return template.execute(status -> { + Result result = template.execute(status -> { try { - Result, PurchaseContext>> result = purchaseContextManager.findBy(purchaseContextType, eventName) + Result confirmationResult = purchaseContextManager.findBy(purchaseContextType, eventName) .map(purchaseContext -> ticketReservationRepository.findOptionalReservationById(reservationId) .filter(r -> r.getStatus() == TicketReservationStatus.PENDING || r.getStatus() == TicketReservationStatus.STUCK) .map(r -> performConfirmation(reservationId, purchaseContext, r, notification, username, subscriptionId)) .orElseGet(() -> Result.error(ErrorCode.ReservationError.UPDATE_FAILED)) ).orElseGet(() -> Result.error(ErrorCode.ReservationError.NOT_FOUND)); - if(!result.isSuccess()) { + if(!confirmationResult.isSuccess()) { log.debug("Reservation confirmation failed for eventName: {} reservationId: {}, username: {}", eventName, reservationId, username); status.setRollbackOnly(); } - return result; + return confirmationResult; } catch (Exception e) { log.error("Error during confirmation of reservation eventName: {} reservationId: {}, username: {}", eventName, reservationId, username); status.setRollbackOnly(); return Result.error(singletonList(ErrorCode.custom("", e.getMessage()))); } }); + return requireNonNull(result).flatMap(this::loadReservation); } public Result, PurchaseContext>> confirmReservation(PurchaseContextType purchaseContextType, String eventName, @@ -247,7 +251,7 @@ private void sendTicketToAttendees(Event event, TicketReservation reservation, P .forEach(t -> { Locale locale = LocaleUtil.forLanguageTag(t.getUserLanguage()); var additionalInfo = ticketReservationManager.retrieveAttendeeAdditionalInfoForTicket(t); - ticketReservationManager.sendTicketByEmail(t, locale, event, ticketReservationManager.getTicketEmailGenerator(event, reservation, locale, additionalInfo)); + reservationEmailContentHelper.sendTicketByEmail(t, locale, event, ticketReservationManager.getTicketEmailGenerator(event, reservation, locale, additionalInfo)); }); } @@ -357,12 +361,12 @@ private Result, PurchaseContext>> loadRes .orElseGet(() -> Result.error(ErrorCode.ReservationError.NOT_FOUND)); } - private Result, PurchaseContext>> performConfirmation(String reservationId, - PurchaseContext purchaseContext, - TicketReservation original, - Notification notification, - String username, - UUID subscriptionId) { + private Result performConfirmation(String reservationId, + PurchaseContext purchaseContext, + TicketReservation original, + Notification notification, + String username, + UUID subscriptionId) { try { var reservation = original; @@ -417,7 +421,7 @@ private Result, PurchaseContext>> perform notification.isCustomer(), notification.isAttendees(), username); - return loadReservation(reservationId); + return Result.success(reservationId); } catch(Exception e) { return Result.error(ErrorCode.ReservationError.UPDATE_FAILED); } diff --git a/src/main/java/alfio/manager/BillingDocumentManager.java b/src/main/java/alfio/manager/BillingDocumentManager.java index b3ed54a0e4..9a1d62e4be 100644 --- a/src/main/java/alfio/manager/BillingDocumentManager.java +++ b/src/main/java/alfio/manager/BillingDocumentManager.java @@ -48,6 +48,7 @@ import static alfio.model.TicketReservation.TicketReservationStatus.CANCELLED; import static alfio.model.TicketReservation.TicketReservationStatus.PENDING; import static alfio.model.system.ConfigurationKeys.*; +import static alfio.util.ReservationUtil.collectTicketsWithCategory; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.stream.Collectors.groupingBy; @@ -86,7 +87,7 @@ static boolean mustGenerateBillingDocument(OrderSummary summary, TicketReservati return !summary.getFree() && (!summary.getNotYetPaid() || (summary.getWaitingForPayment() && ticketReservation.isInvoiceRequested())); } - List generateBillingDocumentAttachment(PurchaseContext purchaseContext, + public List generateBillingDocumentAttachment(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, BillingDocument.Type documentType, @@ -96,7 +97,7 @@ List generateBillingDocumentAttachment(PurchaseContext purcha model.put("reservationId", ticketReservation.getId()); model.put("eventId", purchaseContext.event().map(ev -> Integer.toString(ev.getId())).orElse(null)); model.put("language", json.asJsonString(language)); - model.put("reservationEmailModel", json.asJsonString(getOrCreateBillingDocument(purchaseContext, ticketReservation, username, orderSummary).getModel())); + model.put("reservationEmailModel", json.asJsonString(internalGetOrCreate(purchaseContext, ticketReservation, username, orderSummary).getModel())); switch (documentType) { case INVOICE: return Collections.singletonList(new Mailer.Attachment("invoice.pdf", null, APPLICATION_PDF, model, Mailer.AttachmentIdentifier.INVOICE_PDF)); @@ -144,6 +145,10 @@ BillingDocument createBillingDocument(PurchaseContext purchaseContext, TicketRes @Transactional public BillingDocument getOrCreateBillingDocument(PurchaseContext purchaseContext, TicketReservation reservation, String username, OrderSummary orderSummary) { + return internalGetOrCreate(purchaseContext, reservation, username, orderSummary); + } + + private BillingDocument internalGetOrCreate(PurchaseContext purchaseContext, TicketReservation reservation, String username, OrderSummary orderSummary) { Optional existing = billingDocumentRepository.findLatestByReservationId(reservation.getId()); return existing.orElseGet(() -> createBillingDocument(purchaseContext, reservation, username, orderSummary)); } @@ -220,15 +225,7 @@ private Map prepareModelForBillingDocument(PurchaseContext purch Map> ticketsByCategory = ticketRepository.findTicketsInReservation(reservation.getId()) .stream() .collect(groupingBy(Ticket::getCategoryId)); - final List ticketsWithCategory; - if(!ticketsByCategory.isEmpty()) { - ticketsWithCategory = ticketCategoryRepository.findByIds(ticketsByCategory.keySet()) - .stream() - .flatMap(tc -> ticketsByCategory.get(tc.getId()).stream().map(t -> new TicketWithCategory(t, tc))) - .collect(toList()); - } else { - ticketsWithCategory = Collections.emptyList(); - } + List ticketsWithCategory = collectTicketsWithCategory(ticketsByCategory, ticketCategoryRepository); var reservationShortId = configurationManager.getShortReservationID(purchaseContext, reservation); Map model = TemplateResource.prepareModelForConfirmationEmail(organization, purchaseContext, reservation, vat, ticketsWithCategory, summary, "", "", reservationShortId, invoiceAddress, bankAccountNr, bankAccountOwner, Map.of()); boolean euBusiness = StringUtils.isNotBlank(reservation.getVatCountryCode()) && StringUtils.isNotBlank(reservation.getVatNr()) diff --git a/src/main/java/alfio/manager/ExtensionManager.java b/src/main/java/alfio/manager/ExtensionManager.java index c1aa2c5e75..1933ece4b6 100644 --- a/src/main/java/alfio/manager/ExtensionManager.java +++ b/src/main/java/alfio/manager/ExtensionManager.java @@ -62,7 +62,6 @@ import static alfio.extension.ExtensionService.toPath; import static alfio.manager.support.extension.ExtensionEvent.*; import static alfio.model.PromoCodeDiscount.DiscountType.PERCENTAGE; -import static java.util.stream.Collectors.toSet; @Component @AllArgsConstructor @@ -198,7 +197,7 @@ void handleStuckReservations(Event event, List stuckReservationsId) { asyncCall(ExtensionEvent.STUCK_RESERVATIONS, event, payload); } - Optional handleReservationEmailCustomText(PurchaseContext purchaseContext, TicketReservation reservation, TicketReservationAdditionalInfo additionalInfo) { + public Optional handleReservationEmailCustomText(PurchaseContext purchaseContext, TicketReservation reservation, TicketReservationAdditionalInfo additionalInfo) { Map payload = Map.of( RESERVATION, reservation, "purchaseContext", purchaseContext, @@ -260,7 +259,7 @@ public Optional handleInvoiceGeneration(PaymentSpecification payload.put("vatNr", billingDetails.getTaxId()); payload.put("vatStatus", spec.getVatStatus()); - return Optional.ofNullable(syncCall(ExtensionEvent.INVOICE_GENERATION, spec.getPurchaseContext(), payload, InvoiceGeneration.class)); + return Optional.ofNullable(syncCall(ExtensionEvent.INVOICE_GENERATION, spec.getPurchaseContext(), payload, InvoiceGeneration.class, false)); } public Optional handleCreditNoteGeneration(PurchaseContext purchaseContext, diff --git a/src/main/java/alfio/manager/OrganizationDeleter.java b/src/main/java/alfio/manager/OrganizationDeleter.java index 66c59dac2c..7207203744 100644 --- a/src/main/java/alfio/manager/OrganizationDeleter.java +++ b/src/main/java/alfio/manager/OrganizationDeleter.java @@ -16,9 +16,7 @@ */ package alfio.manager; -import alfio.repository.EventDeleterRepository; -import alfio.repository.EventRepository; -import alfio.repository.OrganizationDeleterRepository; +import alfio.repository.*; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.join.UserOrganizationRepository; import alfio.util.RequestUtils; diff --git a/src/main/java/alfio/manager/PaymentManager.java b/src/main/java/alfio/manager/PaymentManager.java index e79be56944..3156cedc87 100644 --- a/src/main/java/alfio/manager/PaymentManager.java +++ b/src/main/java/alfio/manager/PaymentManager.java @@ -169,7 +169,7 @@ Audit.EventType.REFUND_ATTEMPT_FAILED, new Date(), Audit.EntityType.RESERVATION, return res; } - TransactionAndPaymentInfo getInfo(TicketReservation reservation, PurchaseContext purchaseContext) { + public TransactionAndPaymentInfo getInfo(TicketReservation reservation, PurchaseContext purchaseContext) { Optional maybeTransaction = transactionRepository.loadOptionalByReservationId(reservation.getId()) .map(transaction -> internalGetInfo(reservation, purchaseContext, transaction)); maybeTransaction.ifPresent(info -> { diff --git a/src/main/java/alfio/manager/ReservationFinalizer.java b/src/main/java/alfio/manager/ReservationFinalizer.java new file mode 100644 index 0000000000..51d685705a --- /dev/null +++ b/src/main/java/alfio/manager/ReservationFinalizer.java @@ -0,0 +1,472 @@ +/** + * 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.manager; + +import alfio.manager.payment.PaymentSpecification; +import alfio.manager.support.FeeCalculator; +import alfio.manager.support.IncompatibleStateException; +import alfio.manager.support.RetryFinalizeReservation; +import alfio.manager.support.reservation.OrderSummaryGenerator; +import alfio.manager.support.reservation.ReservationAuditingHelper; +import alfio.manager.support.reservation.ReservationCostCalculator; +import alfio.manager.support.reservation.ReservationEmailContentHelper; +import alfio.manager.system.AdminJobManager; +import alfio.manager.system.ConfigurationLevel; +import alfio.manager.system.ConfigurationManager; +import alfio.model.*; +import alfio.model.metadata.TicketMetadata; +import alfio.model.metadata.TicketMetadataContainer; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.model.support.UserIdAndOrganizationId; +import alfio.model.system.command.FinalizeReservation; +import alfio.model.transaction.PaymentProxy; +import alfio.model.transaction.Transaction; +import alfio.repository.*; +import alfio.repository.system.AdminJobQueueRepository; +import alfio.repository.user.UserRepository; +import alfio.util.ClockProvider; +import alfio.util.Json; +import alfio.util.LocaleUtil; +import alfio.util.ReservationUtil; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.util.*; +import java.util.function.Function; + +import static alfio.manager.system.AdminJobExecutor.JobName.RETRY_RESERVATION_CONFIRMATION; +import static alfio.model.Audit.EventType.SUBSCRIPTION_ACQUIRED; +import static alfio.model.TicketReservation.TicketReservationStatus.*; +import static alfio.model.system.ConfigurationKeys.*; +import static alfio.util.ReservationUtil.hasPrivacyPolicy; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static java.util.Objects.requireNonNullElse; +import static java.util.stream.Collectors.toMap; + +@Component +public class ReservationFinalizer { + private static final Logger log = LoggerFactory.getLogger(ReservationFinalizer.class); + private final TransactionTemplate transactionTemplate; + private final TicketReservationRepository ticketReservationRepository; + private final UserRepository userRepository; + private final ExtensionManager extensionManager; + private final AuditingRepository auditingRepository; + private final ClockProvider clockProvider; + private final ConfigurationManager configurationManager; + private final SubscriptionRepository subscriptionRepository; + private final ReservationAuditingHelper auditingHelper; + private final TicketRepository ticketRepository; + private final ReservationEmailContentHelper reservationOperationHelper; + private final SpecialPriceRepository specialPriceRepository; + private final WaitingQueueManager waitingQueueManager; + private final TicketCategoryRepository ticketCategoryRepository; + private final ReservationCostCalculator reservationCostCalculator; + private final BillingDocumentManager billingDocumentManager; + private final AdditionalServiceItemRepository additionalServiceItemRepository; + private final OrderSummaryGenerator orderSummaryGenerator; + private final ReservationEmailContentHelper reservationHelper; + private final TransactionRepository transactionRepository; + private final AdminJobQueueRepository adminJobQueueRepository; + private final PurchaseContextManager purchaseContextManager; + private final Json json; + + + public ReservationFinalizer(PlatformTransactionManager transactionManager, + TicketReservationRepository ticketReservationRepository, + UserRepository userRepository, + ExtensionManager extensionManager, + AuditingRepository auditingRepository, + ClockProvider clockProvider, + ConfigurationManager configurationManager, + SubscriptionRepository subscriptionRepository, + TicketRepository ticketRepository, + ReservationEmailContentHelper reservationEmailContentHelper, + SpecialPriceRepository specialPriceRepository, + WaitingQueueManager waitingQueueManager, + TicketCategoryRepository ticketCategoryRepository, + ReservationCostCalculator reservationCostCalculator, + BillingDocumentManager billingDocumentManager, + AdditionalServiceItemRepository additionalServiceItemRepository, + OrderSummaryGenerator orderSummaryGenerator, + TransactionRepository transactionRepository, + AdminJobQueueRepository adminJobQueueRepository, + PurchaseContextManager purchaseContextManager, + Json json) { + this.ticketReservationRepository = ticketReservationRepository; + this.userRepository = userRepository; + this.extensionManager = extensionManager; + this.auditingRepository = auditingRepository; + this.clockProvider = clockProvider; + this.configurationManager = configurationManager; + this.subscriptionRepository = subscriptionRepository; + this.ticketRepository = ticketRepository; + this.reservationOperationHelper = reservationEmailContentHelper; + this.specialPriceRepository = specialPriceRepository; + this.waitingQueueManager = waitingQueueManager; + this.ticketCategoryRepository = ticketCategoryRepository; + this.reservationCostCalculator = reservationCostCalculator; + this.billingDocumentManager = billingDocumentManager; + this.additionalServiceItemRepository = additionalServiceItemRepository; + this.transactionRepository = transactionRepository; + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + this.transactionTemplate = new TransactionTemplate(transactionManager, definition); + this.orderSummaryGenerator = orderSummaryGenerator; + this.reservationHelper = reservationEmailContentHelper; + this.auditingHelper = new ReservationAuditingHelper(auditingRepository); + this.adminJobQueueRepository = adminJobQueueRepository; + this.purchaseContextManager = purchaseContextManager; + this.json = json; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void finalizeCommandReceived(FinalizeReservation finalizeReservation) { + transactionTemplate.executeWithoutResult(ctx -> processFinalizeReservation(finalizeReservation, ctx, true)); + } + + public void retryFinalizeReservation(RetryFinalizeReservation retryFinalizeReservation) { + var purchaseContextAndReservation = purchaseContextManager.getReservationWithPurchaseContext(retryFinalizeReservation.getReservationId()).orElseThrow(); + var reservation = purchaseContextAndReservation.getRight(); + var purchaseContext = purchaseContextAndReservation.getLeft(); + var costResult = reservationCostCalculator.totalReservationCostWithVAT(reservation); + var totalPrice = costResult.getLeft(); + var orderSummary = orderSummaryGenerator.orderSummaryForReservation(reservation, purchaseContext); + var paymentSpecification = new PaymentSpecification(reservation, totalPrice, purchaseContext, null, orderSummary, retryFinalizeReservation.isTcAccepted(), retryFinalizeReservation.isPrivacyPolicyAccepted()); + transactionTemplate.executeWithoutResult(ctx -> processFinalizeReservation(new FinalizeReservation(paymentSpecification, retryFinalizeReservation.getPaymentProxy(), retryFinalizeReservation.isSendReservationConfirmationEmail(), retryFinalizeReservation.isSendTickets(), retryFinalizeReservation.getUsername(), retryFinalizeReservation.getOriginalStatus()), ctx, false)); + } + + private void processFinalizeReservation(FinalizeReservation finalizeReservation, TransactionStatus ctx, boolean scheduleRetryOnError) { + Object savepoint = ctx.createSavepoint(); + var spec = finalizeReservation.getPaymentSpecification(); + try { + var reservation = ticketReservationRepository.findReservationById(spec.getReservationId()); + var totalPrice = reservationCostCalculator.totalReservationCostWithVAT(reservation); + var metadata = ticketReservationRepository.getMetadata(reservation.getId()); + // generate invoice number + if (reservation.getStatus() != COMPLETE && StringUtils.isBlank(reservation.getInvoiceNumber()) && !metadata.isReadyForConfirmation()) { + boolean traceEnabled = log.isTraceEnabled(); + if (traceEnabled) { + log.trace("Generating invoice number for reservation {}", reservation.getId()); + } + var invoiceNumberOptional = billingDocumentManager.generateInvoiceNumber(spec, totalPrice.getKey()); + invoiceNumberOptional.ifPresent(s -> setInvoiceNumber(spec.getReservationId(), s)); + } + // no exceptions here. We can save the progress + ticketReservationRepository.setMetadata(reservation.getId(), metadata.withReadyForConfirmation(true)); + ctx.releaseSavepoint(savepoint); + savepoint = ctx.createSavepoint(); + + // complete reservation + completeReservation(finalizeReservation); + } catch(Exception e) { + ctx.rollbackToSavepoint(savepoint); + if (!scheduleRetryOnError) { + throw e; + } + boolean scheduled = AdminJobManager.executionScheduler( + RETRY_RESERVATION_CONFIRMATION, + Map.of("payload", json.asJsonString(RetryFinalizeReservation.fromFinalizeReservation(finalizeReservation))), + ZonedDateTime.now(clockProvider.getClock()).plusSeconds(2L) + ).apply(adminJobQueueRepository); + if(!scheduled) { + log.warn("Cannot schedule retry for reservation {}", spec.getReservationId()); + // throw exception only if we can't schedule the retry + throw e; + } else { + log.warn("Error while confirming reservation "+ spec.getReservationId() + ". Will retry in 2s", e); + } + } finally { + ctx.releaseSavepoint(savepoint); + } + } + + + private void setInvoiceNumber(String reservationId, String invoiceNumber) { + if (log.isTraceEnabled()) { + log.trace("Set invoice number {} for reservation {}", invoiceNumber, reservationId); + } + ticketReservationRepository.setInvoiceNumber(reservationId, invoiceNumber); + } + + private void completeReservation(FinalizeReservation finalizeReservation) { + var spec = finalizeReservation.getPaymentSpecification(); + var paymentProxy = finalizeReservation.getPaymentProxy(); + var username = finalizeReservation.getUsername(); + var reservationId = spec.getReservationId(); + var purchaseContext = spec.getPurchaseContext(); + final TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); + if (reservation.getStatus() != FINALIZING && reservation.getStatus() != OFFLINE_FINALIZING) { + throw new IncompatibleStateException("Status " + reservation.getStatus() + " is not compatible with finalization."); + } + var metadata = ticketReservationRepository.getMetadata(reservationId); + if (!metadata.isReadyForConfirmation()) { + throw new IncompatibleStateException("Reservation is not ready to be confirmed"); + } + // retrieve reservation owner if username is null + Integer userId; + if(username != null) { + userId = userRepository.getByUsername(username).getId(); + } else { + userId = ticketReservationRepository.getReservationOwnerAndOrganizationId(reservationId) + .map(UserIdAndOrganizationId::getUserId) + .orElse(null); + } + ticketReservationRepository.setMetadata(reservationId, metadata.withFinalized(true)); + Locale locale = LocaleUtil.forLanguageTag(reservation.getUserLanguage()); + List tickets = null; + if(paymentProxy != PaymentProxy.OFFLINE) { + ticketReservationRepository.updateReservationStatus(reservationId, COMPLETE.name()); + tickets = acquireItems(paymentProxy, reservationId, spec.getEmail(), spec.getCustomerName(), spec.getLocale().getLanguage(), spec.getBillingAddress(), spec.getCustomerReference(), spec.getPurchaseContext(), finalizeReservation.isSendTickets()); + extensionManager.handleReservationConfirmation(reservation, ticketReservationRepository.getBillingDetailsForReservation(reservationId), spec.getPurchaseContext()); + } else { + // if paymentProxy is offline, we set the appropriate status to wait for payment + ticketReservationRepository.updateReservationStatus(reservationId, finalizeReservation.getOriginalStatus().name()); + } + + Date eventTime = new Date(); + auditingRepository.insert(reservationId, userId, purchaseContext, Audit.EventType.RESERVATION_COMPLETE, eventTime, Audit.EntityType.RESERVATION, reservationId); + ticketReservationRepository.updateRegistrationTimestamp(reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getPurchaseContext().getZoneId()))); + if(spec.isTcAccepted()) { + auditingRepository.insert(reservationId, userId, purchaseContext, Audit.EventType.TERMS_CONDITION_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("termsAndConditionsUrl", spec.getPurchaseContext().getTermsAndConditionsUrl()))); + } + + if(hasPrivacyPolicy(spec.getPurchaseContext()) && spec.isPrivacyAccepted()) { + auditingRepository.insert(reservationId, userId, purchaseContext, Audit.EventType.PRIVACY_POLICY_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("privacyPolicyUrl", spec.getPurchaseContext().getPrivacyPolicyUrl()))); + } + + if(finalizeReservation.isSendReservationConfirmationEmail()) { + TicketReservation updatedReservation = ticketReservationRepository.findReservationById(reservationId); + sendConfirmationEmailIfNecessary(updatedReservation, tickets, purchaseContext, locale, username); + reservationOperationHelper.sendReservationCompleteEmailToOrganizer(spec.getPurchaseContext(), updatedReservation, locale, username); + } + } + + public void sendConfirmationEmailIfNecessary(TicketReservation ticketReservation, + List tickets, + PurchaseContext purchaseContext, + Locale locale, + String username) { + if (!ticketReservationRepository.getMetadata(ticketReservation.getId()).isFinalized()) { + throw new IncompatibleStateException("Reservation confirmed but not yet finalized"); + } + if(purchaseContext.ofType(PurchaseContext.PurchaseContextType.event)) { + var config = configurationManager.getFor(List.of(SEND_RESERVATION_EMAIL_IF_NECESSARY, SEND_TICKETS_AUTOMATICALLY), purchaseContext.getConfigurationLevel()); + if(ticketReservation.getSrcPriceCts() > 0 + || CollectionUtils.isEmpty(tickets) || tickets.size() > 1 + || !tickets.get(0).getEmail().equals(ticketReservation.getEmail()) + || !config.get(SEND_RESERVATION_EMAIL_IF_NECESSARY).getValueAsBooleanOrDefault() + || !config.get(SEND_TICKETS_AUTOMATICALLY).getValueAsBooleanOrDefault() + ) { + reservationOperationHelper.sendConfirmationEmail(purchaseContext, ticketReservation, locale, username); + } + } else { + reservationOperationHelper.sendConfirmationEmail(purchaseContext, ticketReservation, locale, username); + } + } + + public void acquireSpecialPriceTokens(String reservationId) { + specialPriceRepository.updateStatusForReservation(singletonList(reservationId), SpecialPrice.Status.TAKEN.toString()); + } + + private List acquireItems(PaymentProxy paymentProxy, String reservationId, String email, CustomerName customerName, + String userLanguage, String billingAddress, String customerReference, PurchaseContext purchaseContext, boolean sendTickets) { + switch (purchaseContext.getType()) { + case event: { + acquireEventTickets(paymentProxy, reservationId, purchaseContext, purchaseContext.event().orElseThrow()); + break; + } + case subscription: { + acquireSubscription(paymentProxy, reservationId, purchaseContext, customerName, email); + break; + } + default: throw new IllegalStateException("not supported purchase context"); + } + + acquireSpecialPriceTokens(reservationId); + ZonedDateTime timestamp = ZonedDateTime.now(clockProvider.getClock()); + int updatedReservation = ticketReservationRepository.updateTicketReservation(reservationId, COMPLETE.toString(), email, + customerName.getFullName(), customerName.getFirstName(), customerName.getLastName(), userLanguage, billingAddress, timestamp, paymentProxy.toString(), customerReference); + + Validate.isTrue(updatedReservation == 1, "expected exactly one updated reservation, got " + updatedReservation); + + waitingQueueManager.fireReservationConfirmed(reservationId); + //we must notify the plugins about ticket assignment and send them by email + TicketReservation reservation = findById(reservationId).orElseThrow(IllegalStateException::new); + List assignedTickets = findTicketsInReservation(reservationId); + assignedTickets.stream() + .filter(ticket -> StringUtils.isNotBlank(ticket.getFullName()) || StringUtils.isNotBlank(ticket.getFirstName()) || StringUtils.isNotBlank(ticket.getEmail())) + .forEach(ticket -> { + var event = purchaseContext.event().orElseThrow(); + Locale locale = LocaleUtil.forLanguageTag(ticket.getUserLanguage()); + var additionalInfo = reservationOperationHelper.retrieveAttendeeAdditionalInfoForTicket(ticket); + if((paymentProxy != PaymentProxy.ADMIN || sendTickets) && configurationManager.getFor(SEND_TICKETS_AUTOMATICALLY, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault()) { + reservationOperationHelper.sendTicketByEmail(ticket, locale, event, reservationHelper.getTicketEmailGenerator(event, reservation, locale, additionalInfo)); + } + extensionManager.handleTicketAssignment(ticket, ticketCategoryRepository.getById(ticket.getCategoryId()), additionalInfo); + }); + return assignedTickets; + } + + private void acquireSubscription(PaymentProxy paymentProxy, String reservationId, PurchaseContext purchaseContext, CustomerName customerName, String email) { + var status = paymentProxy.isDeskPaymentRequired() ? AllocationStatus.TO_BE_PAID : AllocationStatus.ACQUIRED; + var subscriptionDescriptor = (SubscriptionDescriptor) purchaseContext; + ZonedDateTime validityFrom = null; + ZonedDateTime validityTo = null; + var confirmationTimestamp = subscriptionDescriptor.now(clockProvider); + if(subscriptionDescriptor.getValidityFrom() != null) { + validityFrom = subscriptionDescriptor.getValidityFrom(); + validityTo = subscriptionDescriptor.getValidityTo(); + } else if(subscriptionDescriptor.getValidityUnits() != null) { + validityFrom = confirmationTimestamp; + var temporalUnit = requireNonNullElse(subscriptionDescriptor.getValidityTimeUnit(), SubscriptionDescriptor.SubscriptionTimeUnit.DAYS).getTemporalUnit(); + validityTo = confirmationTimestamp.plus(subscriptionDescriptor.getValidityUnits(), temporalUnit) + .with(ChronoField.HOUR_OF_DAY, 23) + .with(ChronoField.MINUTE_OF_HOUR, 59) + .with(ChronoField.SECOND_OF_MINUTE, 59); + } + var subscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).stream().findFirst().orElseThrow(); + var updatedSubscriptions = subscriptionRepository.confirmSubscription(reservationId, + status, + requireNonNullElse(subscription.getFirstName(), customerName.getFirstName()), + requireNonNullElse(subscription.getLastName(), customerName.getLastName()), + requireNonNullElse(subscription.getEmail(), email), + subscriptionDescriptor.getMaxEntries(), + validityFrom, + validityTo, + confirmationTimestamp, + subscriptionDescriptor.getTimeZone()); + Validate.isTrue(updatedSubscriptions > 0, "must have updated at least one subscription"); + subscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).get(0); // at the moment it's safe because there can be only one subscription per reservation + var subscriptionId = subscription.getId(); + auditingRepository.insert(reservationId, null, purchaseContext, SUBSCRIPTION_ACQUIRED, new Date(), Audit.EntityType.SUBSCRIPTION, subscriptionId.toString()); + extensionManager.handleSubscriptionAssignmentMetadata(subscription, subscriptionDescriptor, subscriptionRepository.getSubscriptionMetadata(subscriptionId)) + .ifPresent(metadata -> subscriptionRepository.setMetadataForSubscription(subscriptionId, metadata)); + } + + private void acquireEventTickets(PaymentProxy paymentProxy, String reservationId, PurchaseContext purchaseContext, Event event) { + Ticket.TicketStatus ticketStatus = paymentProxy.isDeskPaymentRequired() ? Ticket.TicketStatus.TO_BE_PAID : Ticket.TicketStatus.ACQUIRED; + AdditionalServiceItem.AdditionalServiceItemStatus asStatus = paymentProxy.isDeskPaymentRequired() ? AdditionalServiceItem.AdditionalServiceItemStatus.TO_BE_PAID : AdditionalServiceItem.AdditionalServiceItemStatus.ACQUIRED; + Map preUpdateTicket = ticketRepository.findTicketsInReservation(reservationId).stream().collect(toMap(Ticket::getId, Function.identity())); + int updatedTickets = ticketRepository.updateTicketsStatusWithReservationId(reservationId, ticketStatus.toString()); + if(!configurationManager.getFor(ENABLE_TICKET_TRANSFER, purchaseContext.getConfigurationLevel()).getValueAsBooleanOrDefault()) { + //automatically lock assignment + int locked = ticketRepository.forbidReassignment(preUpdateTicket.keySet()); + Validate.isTrue(updatedTickets == locked, "Expected to lock "+updatedTickets+" tickets, locked "+ locked); + Map postUpdateTicket = ticketRepository.findTicketsInReservation(reservationId).stream().collect(toMap(Ticket::getId, Function.identity())); + + postUpdateTicket.forEach( + (id, ticket) -> auditingHelper.auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticket, Collections.emptyMap(), event.getId())); + } + var ticketsWithMetadataById = ticketRepository.findTicketsInReservationWithMetadata(reservationId) + .stream().collect(toMap(twm -> twm.getTicket().getId(), Function.identity())); + ticketsWithMetadataById.forEach((id, ticketWithMetadata) -> { + var newMetadataOptional = extensionManager.handleTicketAssignmentMetadata(ticketWithMetadata, event); + newMetadataOptional.ifPresent(metadata -> { + var existingContainer = TicketMetadataContainer.copyOf(ticketWithMetadata.getMetadata()); + var general = new HashMap<>(existingContainer.getMetadataForKey(TicketMetadataContainer.GENERAL) + .orElseGet(TicketMetadata::empty).getAttributes()); + general.putAll(metadata.getAttributes()); + existingContainer.putMetadata(TicketMetadataContainer.GENERAL, new TicketMetadata(null, null, general)); + ticketRepository.updateTicketMetadata(id, existingContainer); + auditingHelper.auditUpdateMetadata(reservationId, id, event.getId(), existingContainer, ticketWithMetadata.getMetadata()); + }); + auditingHelper.auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticketWithMetadata.getTicket(), Collections.emptyMap(), event.getId()); + }); + int updatedAS = additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservationId, asStatus); + Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); + } + + public void confirmOfflinePayment(Event event, String reservationId, String username) { + TicketReservation ticketReservation = findById(reservationId).orElseThrow(IllegalArgumentException::new); + ticketReservationRepository.lockReservationForUpdate(reservationId); + var metadata = ticketReservationRepository.getMetadata(reservationId); + if (!metadata.isReadyForConfirmation()) { + throw new IncompatibleStateException("Reservation is not ready to be confirmed"); + } + Validate.isTrue(ticketReservation.getPaymentMethod() == PaymentProxy.OFFLINE, "invalid payment method"); + Validate.isTrue(ticketReservation.isPendingOfflinePayment(), "invalid status"); + + + ticketReservationRepository.confirmOfflinePayment(reservationId, COMPLETE.name(), event.now(clockProvider)); + + registerAlfioTransaction(event, reservationId, PaymentProxy.OFFLINE); + + auditingRepository.insert(reservationId, userRepository.findIdByUserName(username).orElse(null), event.getId(), Audit.EventType.RESERVATION_OFFLINE_PAYMENT_CONFIRMED, new Date(), Audit.EntityType.RESERVATION, ticketReservation.getId()); + + ticketReservationRepository.setMetadata(reservationId, metadata.withFinalized(true)); + CustomerName customerName = new CustomerName(ticketReservation.getFullName(), ticketReservation.getFirstName(), ticketReservation.getLastName(), event.mustUseFirstAndLastName()); + acquireItems(PaymentProxy.OFFLINE, reservationId, ticketReservation.getEmail(), customerName, + ticketReservation.getUserLanguage(), ticketReservation.getBillingAddress(), + ticketReservation.getCustomerReference(), event, true); + + Locale language = ReservationUtil.getReservationLocale(ticketReservation); + final TicketReservation finalReservation = ticketReservationRepository.findReservationById(reservationId); + billingDocumentManager.createBillingDocument(event, finalReservation, username, orderSummaryGenerator.orderSummaryForReservation(finalReservation, event)); + var configuration = configurationManager.getFor(EnumSet.of(DEFERRED_BANK_TRANSFER_ENABLED, DEFERRED_BANK_TRANSFER_SEND_CONFIRMATION_EMAIL), ConfigurationLevel.event(event)); + if(!configuration.get(DEFERRED_BANK_TRANSFER_ENABLED).getValueAsBooleanOrDefault() || configuration.get(DEFERRED_BANK_TRANSFER_SEND_CONFIRMATION_EMAIL).getValueAsBooleanOrDefault()) { + reservationHelper.sendConfirmationEmail(event, findById(reservationId).orElseThrow(IllegalArgumentException::new), language, username); + } + extensionManager.handleReservationConfirmation(finalReservation, ticketReservationRepository.getBillingDetailsForReservation(reservationId), event); + } + + public void registerAlfioTransaction(Event event, String reservationId, PaymentProxy paymentProxy) { + var totalPrice = reservationCostCalculator.totalReservationCostWithVAT(reservationId).getLeft(); + int priceWithVAT = totalPrice.getPriceWithVAT(); + long platformFee = FeeCalculator.getCalculator(event, configurationManager, requireNonNullElse(totalPrice.getCurrencyCode(), event.getCurrency())) + .apply(ticketRepository.countTicketsInReservation(reservationId), (long) priceWithVAT) + .orElse(0L); + + //FIXME we must support multiple transactions for a reservation, otherwise we can't handle properly the case of ON_SITE payments + + var transactionOptional = transactionRepository.loadOptionalByReservationId(reservationId); + String transactionId = paymentProxy.getKey() + "-" + System.currentTimeMillis(); + if(transactionOptional.isEmpty()) { + transactionRepository.insert(transactionId, null, reservationId, event.now(clockProvider), + priceWithVAT, event.getCurrency(), "Offline payment confirmed for "+reservationId, paymentProxy.getKey(), + platformFee, 0L, Transaction.Status.COMPLETE, Map.of()); + } else if(paymentProxy == PaymentProxy.OFFLINE) { + var transaction = transactionOptional.get(); + transactionRepository.update(transaction.getId(), transactionId, null, event.now(clockProvider), + platformFee, 0L, Transaction.Status.COMPLETE, Map.of()); + } else { + log.warn("ON-Site check-in: ignoring transaction registration for reservationId {}", reservationId); + } + } + + private Optional findById(String reservationId) { + return ticketReservationRepository.findOptionalReservationById(reservationId); + } + + private List findTicketsInReservation(String reservationId) { + return ticketRepository.findTicketsInReservation(reservationId); + } + + +} diff --git a/src/main/java/alfio/manager/TicketReservationManager.java b/src/main/java/alfio/manager/TicketReservationManager.java index e26c23a9d6..525115fb33 100644 --- a/src/main/java/alfio/manager/TicketReservationManager.java +++ b/src/main/java/alfio/manager/TicketReservationManager.java @@ -18,32 +18,27 @@ import alfio.controller.api.support.TicketHelper; import alfio.controller.form.UpdateTicketOwnerForm; -import alfio.controller.support.TemplateProcessor; import alfio.manager.PaymentManager.PaymentMethodDTO.PaymentMethodStatus; import alfio.manager.i18n.MessageSourceManager; import alfio.manager.payment.BankTransferManager; import alfio.manager.payment.PaymentSpecification; import alfio.manager.support.*; +import alfio.manager.support.reservation.*; import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; -import alfio.manager.system.Mailer; -import alfio.manager.system.ReservationPriceCalculator; import alfio.manager.user.UserManager; import alfio.model.*; import alfio.model.AdditionalServiceItem.AdditionalServiceItemStatus; import alfio.model.PriceContainer.VatStatus; import alfio.model.PromoCodeDiscount.CodeType; -import alfio.model.PromoCodeDiscount.DiscountType; import alfio.model.PurchaseContext.PurchaseContextType; import alfio.model.SpecialPrice.Status; import alfio.model.SummaryRow.SummaryType; import alfio.model.Ticket.TicketStatus; import alfio.model.TicketReservation.TicketReservationStatus; import alfio.model.checkin.CheckInFullInfo; -import alfio.model.decorator.AdditionalServiceItemPriceContainer; import alfio.model.decorator.AdditionalServicePriceContainer; import alfio.model.decorator.TicketPriceContainer; -import alfio.model.extension.CustomEmailText; import alfio.model.group.LinkedGroup; import alfio.model.metadata.SubscriptionMetadata; import alfio.model.metadata.TicketMetadata; @@ -56,9 +51,7 @@ import alfio.model.result.Result; import alfio.model.result.WarningMessage; import alfio.model.subscription.*; -import alfio.model.subscription.SubscriptionDescriptor.SubscriptionTimeUnit; -import alfio.model.support.UserIdAndOrganizationId; -import alfio.model.system.ConfigurationKeys; +import alfio.model.system.command.FinalizeReservation; import alfio.model.transaction.*; import alfio.model.transaction.capabilities.OfflineProcessor; import alfio.model.transaction.capabilities.ServerInitiatedTransaction; @@ -69,15 +62,14 @@ import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; import alfio.util.*; -import alfio.util.checkin.TicketCheckInUtil; import lombok.extern.log4j.Log4j2; -import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.time.DateUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.jdbc.UncategorizedSQLException; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; @@ -91,17 +83,14 @@ import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.Assert; import org.springframework.validation.BindingResult; -import org.springframework.web.util.UriComponentsBuilder; import java.math.BigDecimal; import java.security.Principal; import java.time.Clock; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.time.temporal.ChronoField; import java.time.temporal.ChronoUnit; import java.util.*; -import java.util.Map.Entry; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.function.Predicate; @@ -110,27 +99,27 @@ import java.util.stream.Stream; import static alfio.model.Audit.EntityType.RESERVATION; -import static alfio.model.Audit.EntityType.TICKET; import static alfio.model.Audit.EventType.*; -import static alfio.model.BillingDocument.Type.*; +import static alfio.model.BillingDocument.Type.CREDIT_NOTE; import static alfio.model.PromoCodeDiscount.categoriesOrNull; import static alfio.model.TicketReservation.TicketReservationStatus.*; import static alfio.model.subscription.SubscriptionDescriptor.SubscriptionUsageType.ONCE_PER_EVENT; import static alfio.model.system.ConfigurationKeys.*; import static alfio.util.MiscUtils.getAtIndexOrNull; -import static alfio.util.MonetaryUtil.*; +import static alfio.util.MonetaryUtil.formatUnit; +import static alfio.util.MonetaryUtil.unitToCents; +import static alfio.util.ReservationUtil.getReservationLocale; +import static alfio.util.ReservationUtil.hasPrivacyPolicy; import static alfio.util.Wrappers.optionally; import static alfio.util.checkin.TicketCheckInUtil.ticketOnlineCheckInUrl; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; -import static java.util.Collections.singletonMap; import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNullElse; import static java.util.stream.Collectors.*; import static org.apache.commons.lang3.StringUtils.*; import static org.apache.commons.lang3.time.DateUtils.addHours; import static org.apache.commons.lang3.time.DateUtils.truncate; -import static org.springframework.http.MediaType.APPLICATION_PDF; @Component @Transactional @@ -179,26 +168,12 @@ public class TicketReservationManager { private final PurchaseContextManager purchaseContextManager; private final SubscriptionRepository subscriptionRepository; private final UserManager userManager; - - public static class NotEnoughTicketsException extends RuntimeException { - - } - - public static class MissingSpecialPriceTokenException extends RuntimeException { - } - - public static class InvalidSpecialPriceTokenException extends RuntimeException { - - } - - public static class TooManyTicketsForDiscountCodeException extends RuntimeException { - } - - public static class CannotProceedWithPayment extends RuntimeException { - CannotProceedWithPayment(String message) { - super(message); - } - } + private final ApplicationEventPublisher applicationEventPublisher; + private final ReservationEmailContentHelper reservationHelper; + private final ReservationCostCalculator reservationCostCalculator; + private final OrderSummaryGenerator orderSummaryGenerator; + private final ReservationAuditingHelper auditingHelper; + private final ReservationFinalizer reservationFinalizer; public TicketReservationManager(EventRepository eventRepository, OrganizationRepository organizationRepository, @@ -231,7 +206,12 @@ public TicketReservationManager(EventRepository eventRepository, ClockProvider clockProvider, PurchaseContextManager purchaseContextManager, SubscriptionRepository subscriptionRepository, - UserManager userManager) { + UserManager userManager, + ApplicationEventPublisher applicationEventPublisher, + ReservationCostCalculator reservationCostCalculator, + ReservationEmailContentHelper reservationHelper, + ReservationFinalizer reservationFinalizer, + OrderSummaryGenerator orderSummaryGenerator) { this.eventRepository = eventRepository; this.organizationRepository = organizationRepository; this.ticketRepository = ticketRepository; @@ -269,6 +249,12 @@ public TicketReservationManager(EventRepository eventRepository, this.purchaseContextManager = purchaseContextManager; this.subscriptionRepository = subscriptionRepository; this.userManager = userManager; + this.applicationEventPublisher = applicationEventPublisher; + this.reservationCostCalculator = reservationCostCalculator; + this.orderSummaryGenerator = orderSummaryGenerator; + this.reservationHelper = reservationHelper; + this.auditingHelper = new ReservationAuditingHelper(auditingRepository); + this.reservationFinalizer = reservationFinalizer; } private String createSubscriptionReservation(SubscriptionDescriptor subscriptionDescriptor, @@ -684,7 +670,7 @@ public PaymentResult performPayment(PaymentSpecification spec, if (paymentResult.isSuccessful()) { reservation = ticketReservationRepository.findReservationById(spec.getReservationId()); - transitionToComplete(spec, reservationCost, paymentProxy, null); + transitionToComplete(spec, paymentProxy, null); } else if(paymentResult.isFailed()) { reTransitionToPending(spec.getReservationId()); } @@ -694,7 +680,7 @@ public PaymentResult performPayment(PaymentSpecification spec, reTransitionToPending(spec.getReservationId()); } //it is guaranteed that in this case we're dealing with "local" error (e.g. database failure), - //thus it is safer to not rollback the reservation status + //thus it is safer to not roll back the reservation status log.error("unexpected error during payment confirmation", ex); return PaymentResult.failed("error.STEP2_STRIPE_unexpected"); } @@ -737,11 +723,9 @@ public boolean cancelPendingPayment(String reservationId, PurchaseContext purcha return false; } - private void transitionToComplete(PaymentSpecification spec, TotalPrice reservationCost, PaymentProxy paymentProxy, String username) { + private void transitionToComplete(PaymentSpecification spec, PaymentProxy paymentProxy, String username) { var status = ticketReservationRepository.findOptionalStatusAndValidationById(spec.getReservationId()).orElseThrow().getStatus(); if(status != COMPLETE) { - billingDocumentManager.generateInvoiceNumber(spec, reservationCost) - .ifPresent(invoiceNumber -> ticketReservationRepository.setInvoiceNumber(spec.getReservationId(), invoiceNumber)); completeReservation(spec, paymentProxy, true, true, username); } } @@ -812,176 +796,21 @@ private boolean acquireGroupMembers(String reservationId, PurchaseContext purcha } public void confirmOfflinePayment(Event event, String reservationId, String username) { - TicketReservation ticketReservation = findById(reservationId).orElseThrow(IllegalArgumentException::new); - ticketReservationRepository.lockReservationForUpdate(reservationId); - Validate.isTrue(ticketReservation.getPaymentMethod() == PaymentProxy.OFFLINE, "invalid payment method"); - Validate.isTrue(ticketReservation.isPendingOfflinePayment(), "invalid status"); - - - ticketReservationRepository.confirmOfflinePayment(reservationId, TicketReservationStatus.COMPLETE.name(), event.now(clockProvider)); - - registerAlfioTransaction(event, reservationId, PaymentProxy.OFFLINE); - - auditingRepository.insert(reservationId, userRepository.findIdByUserName(username).orElse(null), event.getId(), Audit.EventType.RESERVATION_OFFLINE_PAYMENT_CONFIRMED, new Date(), Audit.EntityType.RESERVATION, ticketReservation.getId()); - - CustomerName customerName = new CustomerName(ticketReservation.getFullName(), ticketReservation.getFirstName(), ticketReservation.getLastName(), event.mustUseFirstAndLastName()); - acquireItems(PaymentProxy.OFFLINE, reservationId, ticketReservation.getEmail(), customerName, - ticketReservation.getUserLanguage(), ticketReservation.getBillingAddress(), - ticketReservation.getCustomerReference(), event, true); - - Locale language = findReservationLanguage(reservationId); - - final TicketReservation finalReservation = ticketReservationRepository.findReservationById(reservationId); - billingDocumentManager.createBillingDocument(event, finalReservation, username, orderSummaryForReservation(finalReservation, event)); - var configuration = configurationManager.getFor(EnumSet.of(DEFERRED_BANK_TRANSFER_ENABLED, DEFERRED_BANK_TRANSFER_SEND_CONFIRMATION_EMAIL), ConfigurationLevel.event(event)); - if(!configuration.get(DEFERRED_BANK_TRANSFER_ENABLED).getValueAsBooleanOrDefault() || configuration.get(DEFERRED_BANK_TRANSFER_SEND_CONFIRMATION_EMAIL).getValueAsBooleanOrDefault()) { - sendConfirmationEmail(event, findById(reservationId).orElseThrow(IllegalArgumentException::new), language, username); - } - extensionManager.handleReservationConfirmation(finalReservation, ticketReservationRepository.getBillingDetailsForReservation(reservationId), event); + reservationFinalizer.confirmOfflinePayment(event, reservationId, username); } void registerAlfioTransaction(Event event, String reservationId, PaymentProxy paymentProxy) { - var totalPrice = totalReservationCostWithVAT(reservationId).getLeft(); - int priceWithVAT = totalPrice.getPriceWithVAT(); - long platformFee = FeeCalculator.getCalculator(event, configurationManager, requireNonNullElse(totalPrice.getCurrencyCode(), event.getCurrency())) - .apply(ticketRepository.countTicketsInReservation(reservationId), (long) priceWithVAT) - .orElse(0L); - - //FIXME we must support multiple transactions for a reservation, otherwise we can't handle properly the case of ON_SITE payments - - var transactionOptional = transactionRepository.loadOptionalByReservationId(reservationId); - String transactionId = paymentProxy.getKey() + "-" + System.currentTimeMillis(); - if(transactionOptional.isEmpty()) { - transactionRepository.insert(transactionId, null, reservationId, event.now(clockProvider), - priceWithVAT, event.getCurrency(), "Offline payment confirmed for "+reservationId, paymentProxy.getKey(), - platformFee, 0L, Transaction.Status.COMPLETE, Map.of()); - } else if(paymentProxy == PaymentProxy.OFFLINE) { - var transaction = transactionOptional.get(); - transactionRepository.update(transaction.getId(), transactionId, null, event.now(clockProvider), - platformFee, 0L, Transaction.Status.COMPLETE, Map.of()); - } else { - log.warn("ON-Site check-in: ignoring transaction registration for reservationId {}", reservationId); - } - + reservationFinalizer.registerAlfioTransaction(event, reservationId, paymentProxy); } public void sendConfirmationEmail(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, String username) { - String reservationId = ticketReservation.getId(); - - OrderSummary summary = orderSummaryForReservationId(reservationId, purchaseContext); - - List attachments; - if (configurationManager.canGenerateReceiptOrInvoiceToCustomer(purchaseContext)) { // https://github.com/alfio-event/alf.io/issues/573 - attachments = generateAttachmentForConfirmationEmail(purchaseContext, ticketReservation, language, summary, username); - } else{ - attachments = List.of(); - } - var vat = getVAT(purchaseContext); - - List configurations = new ArrayList<>(); - if(purchaseContext.ofType(PurchaseContextType.subscription)) { - var firstSubscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).stream().findFirst().orElseThrow(); - boolean sendSeparateEmailToOwner = !Objects.equals(firstSubscription.getEmail(), ticketReservation.getEmail()); - var metadata = Objects.requireNonNullElseGet(subscriptionRepository.getSubscriptionMetadata(firstSubscription.getId()), SubscriptionMetadata::empty); - Map initialModel = Map.of( - "pin", firstSubscription.getPin(), - "subscriptionId", firstSubscription.getId(), - "includePin", metadata.getConfiguration().isDisplayPin(), - "fullName", firstSubscription.getFirstName() + " " + firstSubscription.getLastName()); - var model = prepareModelForReservationEmail(purchaseContext, ticketReservation, vat, summary, List.of(), initialModel); - var subscriptionAttachments = new ArrayList<>(attachments); - subscriptionAttachments.add(generateSubscriptionAttachment(firstSubscription)); - configurations.add(new ConfirmationEmailConfiguration(TemplateResource.CONFIRMATION_EMAIL_SUBSCRIPTION, firstSubscription.getEmail(), model, sendSeparateEmailToOwner ? List.of() : subscriptionAttachments)); - if(sendSeparateEmailToOwner) { - var separateModel = new HashMap<>(model); - separateModel.put("includePin", false); - separateModel.put("fullName", ticketReservation.getFullName()); - configurations.add(new ConfirmationEmailConfiguration(TemplateResource.CONFIRMATION_EMAIL_SUBSCRIPTION, ticketReservation.getEmail(), separateModel, subscriptionAttachments)); - } - } else { - var model = prepareModelForReservationEmail(purchaseContext, ticketReservation, vat, summary, ticketRepository.findTicketsInReservation(ticketReservation.getId()), Map.of()); - configurations.add(new ConfirmationEmailConfiguration(TemplateResource.CONFIRMATION_EMAIL, ticketReservation.getEmail(), model, attachments)); - } - - var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); - var localizedType = messageSource.getMessage("purchase-context."+purchaseContext.getType(), null, language); - configurations.forEach(configuration -> { - notificationManager.sendSimpleEmail(purchaseContext, ticketReservation.getId(), configuration.getEmailAddress(), messageSource.getMessage("reservation-email-subject", - new Object[]{getShortReservationID(purchaseContext, ticketReservation), purchaseContext.getTitle().get(language.getLanguage()), localizedType}, language), - () -> templateManager.renderTemplate(purchaseContext, configuration.getTemplateResource(), configuration.getModel(), language), - configuration.getAttachments()); - }); - } - - private Mailer.Attachment generateSubscriptionAttachment(Subscription subscription) { - var model = new HashMap(); - model.put("subscriptionId", subscription.getId().toString()); - return new Mailer.Attachment("subscription_" + subscription.getId() + ".pdf", null, APPLICATION_PDF.toString(), model, Mailer.AttachmentIdentifier.SUBSCRIPTION_PDF); - } - - private List generateAttachmentForConfirmationEmail(PurchaseContext purchaseContext, - TicketReservation ticketReservation, - Locale language, - OrderSummary summary, - String username) { - if(mustGenerateBillingDocument(summary, ticketReservation)) { //#459 - include PDF invoice in reservation email - BillingDocument.Type type = ticketReservation.getHasInvoiceNumber() ? INVOICE : RECEIPT; - return billingDocumentManager.generateBillingDocumentAttachment(purchaseContext, ticketReservation, language, type, username, summary); - } - return List.of(); - } - - public void sendReservationCompleteEmailToOrganizer(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, String username) { - Organization organization = organizationRepository.getById(purchaseContext.getOrganizationId()); - List cc = notificationManager.getCCForEventOrganizer(purchaseContext); - - Map reservationEmailModel = prepareModelForReservationEmail(purchaseContext, ticketReservation); - - String reservationId = ticketReservation.getId(); - OrderSummary summary = orderSummaryForReservationId(reservationId, purchaseContext); - - List attachments = Collections.emptyList(); - - if (!configurationManager.canGenerateReceiptOrInvoiceToCustomer(purchaseContext) || configurationManager.isInvoiceOnly(purchaseContext)) { // https://github.com/alfio-event/alf.io/issues/573 - attachments = generateAttachmentForConfirmationEmail(purchaseContext, ticketReservation, language, summary, username); - } - - - String shortReservationID = configurationManager.getShortReservationID(purchaseContext, ticketReservation); - notificationManager.sendSimpleEmail(purchaseContext, null, organization.getEmail(), cc, "Reservation complete " + shortReservationID, - () -> templateManager.renderTemplate(purchaseContext, TemplateResource.CONFIRMATION_EMAIL_FOR_ORGANIZER, reservationEmailModel, language), - attachments); - } - - private static boolean mustGenerateBillingDocument(OrderSummary summary, TicketReservation ticketReservation) { - return !summary.getFree() && (!summary.getNotYetPaid() || (summary.getWaitingForPayment() && ticketReservation.isInvoiceRequested())); + this.reservationHelper.sendConfirmationEmail(purchaseContext, ticketReservation, language, username); } - private List generateBillingDocumentAttachment(PurchaseContext purchaseContext, - TicketReservation ticketReservation, - Locale language, - Map billingDocumentModel, - BillingDocument.Type documentType) { - Map model = new HashMap<>(); - model.put(RESERVATION_ID, ticketReservation.getId()); - purchaseContext.event().ifPresent(event -> model.put("eventId", Integer.toString(event.getId()))); - model.put("language", json.asJsonString(language)); - model.put("reservationEmailModel", json.asJsonString(billingDocumentModel));//ticketReservation.getHasInvoiceNumber() - switch (documentType) { - case INVOICE: - return Collections.singletonList(new Mailer.Attachment("invoice.pdf", null, "application/pdf", model, Mailer.AttachmentIdentifier.INVOICE_PDF)); - case RECEIPT: - return Collections.singletonList(new Mailer.Attachment("receipt.pdf", null, "application/pdf", model, Mailer.AttachmentIdentifier.RECEIPT_PDF)); - case CREDIT_NOTE: - return Collections.singletonList(new Mailer.Attachment("credit-note.pdf", null, "application/pdf", model, Mailer.AttachmentIdentifier.CREDIT_NOTE_PDF)); - default: - throw new IllegalStateException(documentType+" is not supported"); - } - } private Locale findReservationLanguage(String reservationId) { - return ticketReservationRepository.findOptionalReservationById(reservationId).map(TicketReservationManager::getReservationLocale).orElse(Locale.ENGLISH); + return ticketReservationRepository.findOptionalReservationById(reservationId).map(ReservationUtil::getReservationLocale).orElse(Locale.ENGLISH); } public void deleteOfflinePayment(Event event, String reservationId, boolean expired, boolean credit, boolean notify, String username) { @@ -992,9 +821,9 @@ public void deleteOfflinePayment(Event event, String reservationId, boolean expi creditReservation(reservation, username, notify); } else { if (notify) { - Map emailModel = prepareModelForReservationEmail(event, reservation); + Map emailModel = reservationHelper.prepareModelForReservationEmail(event, reservation); Locale reservationLanguage = findReservationLanguage(reservationId); - String subject = getReservationEmailSubject(event, reservationLanguage, "reservation-email-expired-subject", reservation.getId()); + String subject = reservationHelper.getReservationEmailSubject(event, reservationLanguage, "reservation-email-expired-subject", reservation.getId()); notificationManager.sendSimpleEmail(event, reservationId, reservation.getEmail(), subject, () -> templateManager.renderTemplate(event, TemplateResource.OFFLINE_RESERVATION_EXPIRED_EMAIL, emailModel, reservationLanguage) ); @@ -1003,28 +832,24 @@ public void deleteOfflinePayment(Event event, String reservationId, boolean expi } } - private String getReservationEmailSubject(PurchaseContext purchaseContext, Locale reservationLanguage, String key, String id) { - return messageSourceManager.getMessageSourceFor(purchaseContext) - .getMessage(key, new Object[]{id, purchaseContext.getDisplayName()}, reservationLanguage); - } - @Transactional public void issueCreditNoteForReservation(PurchaseContext purchaseContext, TicketReservation reservation, String username, boolean sendEmail) { var reservationId = reservation.getId(); ticketReservationRepository.updateReservationStatus(reservationId, TicketReservationStatus.CREDIT_NOTE_ISSUED.toString()); auditingRepository.insert(reservationId, userRepository.nullSafeFindIdByUserName(username).orElse(null), purchaseContext, Audit.EventType.CREDIT_NOTE_ISSUED, new Date(), RESERVATION, reservationId); - var model = prepareModelForReservationEmail(purchaseContext, reservation, getVAT(purchaseContext), orderSummaryForReservation(reservation, purchaseContext), ticketRepository.findTicketsInReservation(reservation.getId()), Map.of()); + var model = prepareModelForReservationEmail(purchaseContext, reservation, reservationHelper.getVAT(purchaseContext), orderSummaryForReservation(reservation, purchaseContext), ticketRepository.findTicketsInReservation(reservation.getId()), Map.of()); BillingDocument billingDocument = billingDocumentManager.createBillingDocument(purchaseContext, reservation, username, BillingDocument.Type.CREDIT_NOTE, orderSummaryForReservation(reservation, purchaseContext)); var organization = organizationRepository.getById(purchaseContext.getOrganizationId()); extensionManager.handleCreditNoteGenerated(reservation, purchaseContext, ((OrderSummary) model.get("orderSummary")).getOriginalTotalPrice(), billingDocument.getId(), Map.of(ORGANIZATION, organization)); if(sendEmail) { + var reservationLocale = getReservationLocale(reservation); notificationManager.sendSimpleEmail(purchaseContext, reservationId, reservation.getEmail(), - getReservationEmailSubject(purchaseContext, getReservationLocale(reservation), "credit-note-issued-email-subject", reservation.getId()), - () -> templateManager.renderTemplate(purchaseContext, TemplateResource.CREDIT_NOTE_ISSUED_EMAIL, model, getReservationLocale(reservation)), - generateBillingDocumentAttachment(purchaseContext, reservation, getReservationLocale(reservation), billingDocument.getModel(), CREDIT_NOTE) + reservationHelper.getReservationEmailSubject(purchaseContext, reservationLocale, "credit-note-issued-email-subject", reservation.getId()), + () -> templateManager.renderTemplate(purchaseContext, TemplateResource.CREDIT_NOTE_ISSUED_EMAIL, model, reservationLocale), + reservationHelper.generateBillingDocumentAttachment(purchaseContext, reservation, reservationLocale, billingDocument.getModel(), CREDIT_NOTE) ); } } @@ -1076,83 +901,16 @@ public Map prepareModelForReservationEmail(PurchaseContext purch OrderSummary summary, List ticketsToInclude, Map initialOptions) { - Organization organization = organizationRepository.getById(purchaseContext.getOrganizationId()); - String baseUrl = configurationManager.baseUrl(purchaseContext); - var reservationId = reservation.getId(); - String reservationUrl = reservationUrl(reservationId); - String reservationShortID = getShortReservationID(purchaseContext, reservation); - - var bankingInfo = configurationManager.getFor(Set.of(INVOICE_ADDRESS, BANK_ACCOUNT_NR, BANK_ACCOUNT_OWNER), ConfigurationLevel.purchaseContext(purchaseContext)); - Optional invoiceAddress = bankingInfo.get(INVOICE_ADDRESS).getValue(); - Optional bankAccountNr = bankingInfo.get(BANK_ACCOUNT_NR).getValue(); - Optional bankAccountOwner = bankingInfo.get(BANK_ACCOUNT_OWNER).getValue(); - - Map> ticketsByCategory = ticketsToInclude - .stream() - .collect(groupingBy(Ticket::getCategoryId)); - final List ticketsWithCategory; - if(!ticketsByCategory.isEmpty()) { - ticketsWithCategory = ticketCategoryRepository.findByIds(ticketsByCategory.keySet()) - .stream() - .flatMap(tc -> ticketsByCategory.get(tc.getId()).stream().map(t -> new TicketWithCategory(t, tc))) - .collect(toList()); - } else { - ticketsWithCategory = Collections.emptyList(); - } - Map baseModel = new HashMap<>(); - baseModel.putAll(initialOptions); - baseModel.putAll(extensionManager.handleReservationEmailCustomText(purchaseContext, reservation, ticketReservationRepository.getAdditionalInfo(reservationId)) - .map(CustomEmailText::toMap) - .orElse(Map.of())); - Map model = TemplateResource.prepareModelForConfirmationEmail(organization, purchaseContext, reservation, vat, ticketsWithCategory, summary, baseUrl, reservationUrl, reservationShortID, invoiceAddress, bankAccountNr, bankAccountOwner, baseModel); - boolean euBusiness = StringUtils.isNotBlank(reservation.getVatCountryCode()) && StringUtils.isNotBlank(reservation.getVatNr()) - && configurationManager.getForSystem(ConfigurationKeys.EU_COUNTRIES_LIST).getRequiredValue().contains(reservation.getVatCountryCode()) - && VatStatus.isVatExempt(reservation.getVatStatus()); - model.put("euBusiness", euBusiness); - model.put("publicId", configurationManager.getPublicReservationID(purchaseContext, reservation)); - model.put("invoicingAdditionalInfo", loadAdditionalInfo(reservationId).getInvoicingAdditionalInfo()); - if(purchaseContext.getType() == PurchaseContextType.event) { - var event = purchaseContext.event().orElseThrow(); - model.put("displayLocation", ticketsWithCategory.stream() - .noneMatch(tc -> EventUtil.isAccessOnline(tc.getCategory(), event))); - } else { - model.put("displayLocation", false); - } - if(ticketReservationRepository.hasSubscriptionApplied(reservationId)) { - model.put("displaySubscriptionUsage", true); - var subscription = subscriptionRepository.findAppliedSubscriptionByReservationId(reservationId).orElseThrow(); - if(subscription.getMaxEntries() > -1) { - var subscriptionUsageDetails = UsageDetails.fromSubscription(subscription, ticketRepository.countSubscriptionUsage(subscription.getId(), null)); - model.put("subscriptionUsageDetails", subscriptionUsageDetails); - model.put("subscriptionUrl", reservationUrl(subscription.getReservationId())); - } - } - return model; - } - - @Transactional(readOnly = true) - public Map prepareModelForReservationEmail(Event event, TicketReservation reservation, Optional vat, OrderSummary summary) { - - var initialOptions = extensionManager.handleReservationEmailCustomText(event, reservation, ticketReservationRepository.getAdditionalInfo(reservation.getId())) - .map(CustomEmailText::toMap) - .orElse(Map.of()); - return prepareModelForReservationEmail(event, reservation, vat, summary, ticketRepository.findTicketsInReservation(reservation.getId()), initialOptions); + return reservationHelper.prepareModelForReservationEmail(purchaseContext, reservation, vat, summary, ticketsToInclude, initialOptions); } public TicketReservationAdditionalInfo loadAdditionalInfo(String reservationId) { return ticketReservationRepository.getAdditionalInfo(reservationId); } - @Transactional(readOnly = true) - public Map prepareModelForReservationEmail(PurchaseContext purchaseContext, TicketReservation reservation) { - Optional vat = getVAT(purchaseContext); - OrderSummary summary = orderSummaryForReservationId(reservation.getId(), purchaseContext); - return prepareModelForReservationEmail(purchaseContext, reservation, vat, summary, ticketRepository.findTicketsInReservation(reservation.getId()), Map.of()); - } - private Map prepareModelForPartialCreditNote(Event event, TicketReservation reservation, List removedTickets) { - var orderSummary = orderSummaryForCreditNote(reservation, event, removedTickets); - var optionalVat = getVAT(event); + var orderSummary = orderSummaryGenerator.orderSummaryForCreditNote(reservation, event, removedTickets); + var optionalVat = reservationHelper.getVAT(event); return prepareModelForReservationEmail(event, reservation, optionalVat, orderSummary, removedTickets, Map.of()); } @@ -1162,7 +920,7 @@ private void transitionToInPayment(PaymentSpecification spec, Principal principa if(optionalStatusAndValidation.isPresent() && optionalStatusAndValidation.get().getStatus() == COMPLETE) { // reservation has been already completed. Let's check if there is a corresponding audit event Validate.isTrue(auditingRepository.countAuditsOfTypeForReservation(spec.getReservationId(), PAYMENT_CONFIRMED) == 1, "Trying to confirm an already paid reservation, but can't find autiting event"); - } else { + } else if(optionalStatusAndValidation.isPresent() && optionalStatusAndValidation.get().getStatus() == PENDING) { int updatedReservation = ticketReservationRepository.updateTicketReservation(spec.getReservationId(), IN_PAYMENT.toString(), spec.getEmail(), spec.getCustomerName().getFullName(), spec.getCustomerName().getFirstName(), spec.getCustomerName().getLastName(), @@ -1222,211 +980,30 @@ public Optional> from(String eventName, } /** - * Set the tickets attached to the reservation to the ACQUIRED state and the ticket reservation to the COMPLETE state. Additionally it will save email/fullName/billingaddress/userLanguage. + * Set the tickets attached to the reservation to the ACQUIRED state and the ticket reservation to the COMPLETE state. + * Additionally, it will save email/fullName/billingAddress/userLanguage. */ void completeReservation(PaymentSpecification spec, PaymentProxy paymentProxy, boolean sendReservationConfirmationEmail, boolean sendTickets, String username) { - String reservationId = spec.getReservationId(); - var purchaseContext = spec.getPurchaseContext(); - final TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); - // retrieve reservation owner if username is null - Integer userId; - if(username != null) { - userId = userRepository.getByUsername(username).getId(); - } else { - userId = ticketReservationRepository.getReservationOwnerAndOrganizationId(reservationId) - .map(UserIdAndOrganizationId::getUserId) - .orElse(null); - } - Locale locale = LocaleUtil.forLanguageTag(reservation.getUserLanguage()); - List tickets = null; - if(paymentProxy != PaymentProxy.OFFLINE) { - tickets = acquireItems(paymentProxy, reservationId, spec.getEmail(), spec.getCustomerName(), spec.getLocale().getLanguage(), spec.getBillingAddress(), spec.getCustomerReference(), spec.getPurchaseContext(), sendTickets); - extensionManager.handleReservationConfirmation(reservation, ticketReservationRepository.getBillingDetailsForReservation(reservationId), spec.getPurchaseContext()); - } - - Date eventTime = new Date(); - auditingRepository.insert(reservationId, userId, purchaseContext, Audit.EventType.RESERVATION_COMPLETE, eventTime, Audit.EntityType.RESERVATION, reservationId); - ticketReservationRepository.updateRegistrationTimestamp(reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getPurchaseContext().getZoneId()))); - if(spec.isTcAccepted()) { - auditingRepository.insert(reservationId, userId, purchaseContext, Audit.EventType.TERMS_CONDITION_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("termsAndConditionsUrl", spec.getPurchaseContext().getTermsAndConditionsUrl()))); - } - - if(eventHasPrivacyPolicy(spec.getPurchaseContext()) && spec.isPrivacyAccepted()) { - auditingRepository.insert(reservationId, userId, purchaseContext, Audit.EventType.PRIVACY_POLICY_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("privacyPolicyUrl", spec.getPurchaseContext().getPrivacyPolicyUrl()))); - } - - if(sendReservationConfirmationEmail) { - TicketReservation updatedReservation = ticketReservationRepository.findReservationById(reservationId); - sendConfirmationEmailIfNecessary(updatedReservation, tickets, purchaseContext, locale, username); - sendReservationCompleteEmailToOrganizer(spec.getPurchaseContext(), updatedReservation, locale, username); - } - } - - void sendConfirmationEmailIfNecessary(TicketReservation ticketReservation, - List tickets, - PurchaseContext purchaseContext, - Locale locale, - String username) { - if(purchaseContext.ofType(PurchaseContextType.event)) { - var config = configurationManager.getFor(List.of(SEND_RESERVATION_EMAIL_IF_NECESSARY, SEND_TICKETS_AUTOMATICALLY), purchaseContext.getConfigurationLevel()); - if(ticketReservation.getSrcPriceCts() > 0 - || CollectionUtils.isEmpty(tickets) || tickets.size() > 1 - || !tickets.get(0).getEmail().equals(ticketReservation.getEmail()) - || !config.get(SEND_RESERVATION_EMAIL_IF_NECESSARY).getValueAsBooleanOrDefault() - || !config.get(SEND_TICKETS_AUTOMATICALLY).getValueAsBooleanOrDefault() - ) { - sendConfirmationEmail(purchaseContext, ticketReservation, locale, username); - } - } else { - sendConfirmationEmail(purchaseContext, ticketReservation, locale, username); - } - } - - private boolean eventHasPrivacyPolicy(PurchaseContext event) { - return StringUtils.isNotBlank(event.getPrivacyPolicyLinkOrNull()); - } - - private List acquireItems(PaymentProxy paymentProxy, String reservationId, String email, CustomerName customerName, - String userLanguage, String billingAddress, String customerReference, PurchaseContext purchaseContext, boolean sendTickets) { - switch (purchaseContext.getType()) { - case event: { - acquireEventTickets(paymentProxy, reservationId, purchaseContext, purchaseContext.event().orElseThrow()); - break; - } - case subscription: { - acquireSubscription(paymentProxy, reservationId, purchaseContext, customerName, email); - break; - } - default: throw new IllegalStateException("not supported purchase context"); + // pre-acquire special price tokens before committing, in order to ensure atomicity + reservationFinalizer.acquireSpecialPriceTokens(spec.getReservationId()); + // update reservation status to mark the finalization, but first retrieve the current one + // set by the payment provider. This is useful especially in case a single "PaymentProxy" can produce different statuses + // like OFFLINE_PAYMENT and DEFERRED_OFFLINE_PAYMENT + var currentStatus = ticketReservationRepository.findOptionalStatusAndValidationById(spec.getReservationId()).orElseThrow().getStatus(); + TicketReservationStatus targetStatus = FINALIZING; + if (currentStatus == OFFLINE_PAYMENT || currentStatus == DEFERRED_OFFLINE_PAYMENT) { + targetStatus = OFFLINE_FINALIZING; } - - specialPriceRepository.updateStatusForReservation(singletonList(reservationId), Status.TAKEN.toString()); - ZonedDateTime timestamp = ZonedDateTime.now(clockProvider.getClock()); - int updatedReservation = ticketReservationRepository.updateTicketReservation(reservationId, TicketReservationStatus.COMPLETE.toString(), email, - customerName.getFullName(), customerName.getFirstName(), customerName.getLastName(), userLanguage, billingAddress, timestamp, paymentProxy.toString(), customerReference); - - - Validate.isTrue(updatedReservation == 1, "expected exactly one updated reservation, got " + updatedReservation); - - waitingQueueManager.fireReservationConfirmed(reservationId); - //we must notify the plugins about ticket assignment and send them by email - TicketReservation reservation = findById(reservationId).orElseThrow(IllegalStateException::new); - List assignedTickets = findTicketsInReservation(reservationId); - assignedTickets.stream() - .filter(ticket -> StringUtils.isNotBlank(ticket.getFullName()) || StringUtils.isNotBlank(ticket.getFirstName()) || StringUtils.isNotBlank(ticket.getEmail())) - .forEach(ticket -> { - var event = purchaseContext.event().orElseThrow(); - Locale locale = LocaleUtil.forLanguageTag(ticket.getUserLanguage()); - var additionalInfo = retrieveAttendeeAdditionalInfoForTicket(ticket); - if((paymentProxy != PaymentProxy.ADMIN || sendTickets) && configurationManager.getFor(SEND_TICKETS_AUTOMATICALLY, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault()) { - sendTicketByEmail(ticket, locale, event, getTicketEmailGenerator(event, reservation, locale, additionalInfo)); - } - extensionManager.handleTicketAssignment(ticket, ticketCategoryRepository.getById(ticket.getCategoryId()), additionalInfo); - }); - return assignedTickets; - } - - private void acquireSubscription(PaymentProxy paymentProxy, String reservationId, PurchaseContext purchaseContext, CustomerName customerName, String email) { - var status = paymentProxy.isDeskPaymentRequired() ? AllocationStatus.TO_BE_PAID : AllocationStatus.ACQUIRED; - var subscriptionDescriptor = (SubscriptionDescriptor) purchaseContext; - ZonedDateTime validityFrom = null; - ZonedDateTime validityTo = null; - var confirmationTimestamp = subscriptionDescriptor.now(clockProvider); - if(subscriptionDescriptor.getValidityFrom() != null) { - validityFrom = subscriptionDescriptor.getValidityFrom(); - validityTo = subscriptionDescriptor.getValidityTo(); - } else if(subscriptionDescriptor.getValidityUnits() != null) { - validityFrom = confirmationTimestamp; - var temporalUnit = requireNonNullElse(subscriptionDescriptor.getValidityTimeUnit(), SubscriptionTimeUnit.DAYS).getTemporalUnit(); - validityTo = confirmationTimestamp.plus(subscriptionDescriptor.getValidityUnits(), temporalUnit) - .with(ChronoField.HOUR_OF_DAY, 23) - .with(ChronoField.MINUTE_OF_HOUR, 59) - .with(ChronoField.SECOND_OF_MINUTE, 59); - } - var subscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).stream().findFirst().orElseThrow(); - var updatedSubscriptions = subscriptionRepository.confirmSubscription(reservationId, - status, - requireNonNullElse(subscription.getFirstName(), customerName.getFirstName()), - requireNonNullElse(subscription.getLastName(), customerName.getLastName()), - requireNonNullElse(subscription.getEmail(), email), - subscriptionDescriptor.getMaxEntries(), - validityFrom, - validityTo, - confirmationTimestamp, - subscriptionDescriptor.getTimeZone()); - Validate.isTrue(updatedSubscriptions > 0, "must have updated at least one subscription"); - subscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).get(0); // at the moment it's safe because there can be only one subscription per reservation - var subscriptionId = subscription.getId(); - auditingRepository.insert(reservationId, null, purchaseContext, SUBSCRIPTION_ACQUIRED, new Date(), Audit.EntityType.SUBSCRIPTION, subscriptionId.toString()); - extensionManager.handleSubscriptionAssignmentMetadata(subscription, subscriptionDescriptor, subscriptionRepository.getSubscriptionMetadata(subscriptionId)) - .ifPresent(metadata -> subscriptionRepository.setMetadataForSubscription(subscriptionId, metadata)); - } - - private void acquireEventTickets(PaymentProxy paymentProxy, String reservationId, PurchaseContext purchaseContext, Event event) { - TicketStatus ticketStatus = paymentProxy.isDeskPaymentRequired() ? TicketStatus.TO_BE_PAID : TicketStatus.ACQUIRED; - AdditionalServiceItemStatus asStatus = paymentProxy.isDeskPaymentRequired() ? AdditionalServiceItemStatus.TO_BE_PAID : AdditionalServiceItemStatus.ACQUIRED; - Map preUpdateTicket = ticketRepository.findTicketsInReservation(reservationId).stream().collect(toMap(Ticket::getId, Function.identity())); - int updatedTickets = ticketRepository.updateTicketsStatusWithReservationId(reservationId, ticketStatus.toString()); - if(!configurationManager.getFor(ENABLE_TICKET_TRANSFER, purchaseContext.getConfigurationLevel()).getValueAsBooleanOrDefault()) { - //automatically lock assignment - int locked = ticketRepository.forbidReassignment(preUpdateTicket.keySet()); - Validate.isTrue(updatedTickets == locked, "Expected to lock "+updatedTickets+" tickets, locked "+ locked); - Map postUpdateTicket = ticketRepository.findTicketsInReservation(reservationId).stream().collect(toMap(Ticket::getId, Function.identity())); - - postUpdateTicket.forEach( - (id, ticket) -> auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticket, Collections.emptyMap(), event.getId())); - } - var ticketsWithMetadataById = ticketRepository.findTicketsInReservationWithMetadata(reservationId) - .stream().collect(toMap(twm -> twm.getTicket().getId(), Function.identity())); - ticketsWithMetadataById.forEach((id, ticketWithMetadata) -> { - var newMetadataOptional = extensionManager.handleTicketAssignmentMetadata(ticketWithMetadata, event); - newMetadataOptional.ifPresent(metadata -> { - var existingContainer = TicketMetadataContainer.copyOf(ticketWithMetadata.getMetadata()); - var general = new HashMap<>(existingContainer.getMetadataForKey(TicketMetadataContainer.GENERAL) - .orElseGet(TicketMetadata::empty).getAttributes()); - general.putAll(metadata.getAttributes()); - existingContainer.putMetadata(TicketMetadataContainer.GENERAL, new TicketMetadata(null, null, general)); - ticketRepository.updateTicketMetadata(id, existingContainer); - auditUpdateMetadata(reservationId, id, event.getId(), existingContainer, ticketWithMetadata.getMetadata()); - }); - auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticketWithMetadata.getTicket(), Collections.emptyMap(), event.getId()); - }); - int updatedAS = additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservationId, asStatus); - Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); + ticketReservationRepository.updateReservationStatus(spec.getReservationId(), targetStatus.name()); + // run detached reservation confirmation + this.applicationEventPublisher.publishEvent(new FinalizeReservation(spec, paymentProxy, sendReservationConfirmationEmail, sendTickets, username, currentStatus)); } public PartialTicketTextGenerator getTicketEmailGenerator(Event event, TicketReservation ticketReservation, Locale ticketLanguage, Map> additionalInfo) { - return ticket -> { - Organization organization = organizationRepository.getById(event.getOrganizationId()); - String ticketUrl = ticketUpdateUrl(event, ticket.getUuid()); - var ticketCategory = ticketCategoryRepository.getById(ticket.getCategoryId()); - - var initialModel = new HashMap<>(extensionManager.handleTicketEmailCustomText(event, ticketReservation, ticketReservationRepository.getAdditionalInfo(ticketReservation.getId()), ticketFieldRepository.findAllByTicketId(ticket.getId())) - .map(CustomEmailText::toMap) - .orElse(Map.of())); - if(EventUtil.isAccessOnline(ticketCategory, event)) { - initialModel.putAll(TicketCheckInUtil.getOnlineCheckInInfo( - extensionManager, - eventRepository, - ticketCategoryRepository, - configurationManager, - event, - ticketLanguage, - ticket, - ticketCategory, - additionalInfo - )); - } - var baseUrl = StringUtils.removeEnd(configurationManager.getFor(BASE_URL, ConfigurationLevel.event(event)).getRequiredValue(), "/"); - var calendarUrl = UriComponentsBuilder.fromUriString(baseUrl + "/api/v2/public/event/{eventShortName}/calendar/{currentLang}") - .queryParam("type", "google") - .build(Map.of("eventShortName", event.getShortName(), "currentLang", ticketLanguage.getLanguage())) - .toString(); - return TemplateProcessor.buildPartialEmail(event, organization, ticketReservation, ticketCategory, templateManager, baseUrl, ticketUrl, calendarUrl, ticketLanguage, initialModel).generate(ticket); - }; + return reservationHelper.getTicketEmailGenerator(event, ticketReservation, ticketLanguage, additionalInfo); } @Transactional @@ -1576,34 +1153,6 @@ private List> findStuckPaymentsToBeNotified(Date .collect(toList()); } - private static Pair> totalReservationCostWithVAT(PromoCodeDiscount promoCodeDiscount, - PurchaseContext purchaseContext, - TicketReservation reservation, - List tickets, - List>> additionalServiceItems, - List subscriptions, - Optional appliedSubscription) { - - String currencyCode = purchaseContext.getCurrency(); - List ticketPrices = tickets.stream().map(t -> TicketPriceContainer.from(t, reservation.getVatStatus(), purchaseContext.getVat(), purchaseContext.getVatStatus(), promoCodeDiscount)).collect(toList()); - int discountedTickets = (int) ticketPrices.stream().filter(t -> t.getAppliedDiscount().compareTo(BigDecimal.ZERO) > 0).count(); - int discountAppliedCount = discountedTickets <= 1 || promoCodeDiscount.getDiscountType() == DiscountType.FIXED_AMOUNT ? discountedTickets : 1; - if(discountAppliedCount == 0 && promoCodeDiscount != null && promoCodeDiscount.getDiscountType() == DiscountType.FIXED_AMOUNT_RESERVATION) { - discountAppliedCount = 1; - } - var reservationPriceCalculator = ReservationPriceCalculator.from(reservation, promoCodeDiscount, tickets, purchaseContext, additionalServiceItems, subscriptions, appliedSubscription); - var price = new TotalPrice(unitToCents(reservationPriceCalculator.getFinalPrice(), currencyCode), - unitToCents(reservationPriceCalculator.getVAT(), currencyCode), - -MonetaryUtil.unitToCents(reservationPriceCalculator.getAppliedDiscount(), currencyCode), - discountAppliedCount, - currencyCode); - return Pair.of(price, Optional.ofNullable(promoCodeDiscount)); - } - - private static Function>, Stream> generateASIPriceContainers(PurchaseContext purchaseContext, PromoCodeDiscount discount) { - return p -> p.getValue().stream().map(asi -> AdditionalServiceItemPriceContainer.from(asi, p.getKey(), purchaseContext, discount)); - } - /** * Get the total cost with VAT if it's not included in the ticket price. * @@ -1611,260 +1160,19 @@ private static Function>, St * @return */ public Pair> totalReservationCostWithVAT(String reservationId) { - return totalReservationCostWithVAT(ticketReservationRepository.findReservationById(reservationId)); + return reservationCostCalculator.totalReservationCostWithVAT(reservationId); } public Pair> totalReservationCostWithVAT(TicketReservation reservation) { - return totalReservationCostWithVAT(purchaseContextManager.findByReservationId(reservation.getId()).orElseThrow(), reservation, ticketRepository.findTicketsInReservation(reservation.getId())); - } - - private Pair> totalReservationCostWithVAT(PurchaseContext purchaseContext, TicketReservation reservation, List tickets) { - var promoCodeDiscount = Optional.ofNullable(reservation.getPromoCodeDiscountId()).map(promoCodeDiscountRepository::findById); - var subscriptions = subscriptionRepository.findSubscriptionsByReservationId(reservation.getId()); - var appliedSubscription = subscriptionRepository.findAppliedSubscriptionByReservationId(reservation.getId()); - return totalReservationCostWithVAT(promoCodeDiscount.orElse(null), purchaseContext, reservation, tickets, - purchaseContext.event().map(event -> collectAdditionalServiceItems(reservation.getId(), event)).orElse(List.of()), - subscriptions, - appliedSubscription); - } - - private String formatPromoCode(PromoCodeDiscount promoCodeDiscount, List tickets, Locale locale, PurchaseContext purchaseContext) { - - if(promoCodeDiscount.getCodeType() == CodeType.DYNAMIC) { - return messageSourceManager.getMessageSourceFor(purchaseContext).getMessage("reservation.dynamic.discount.description", null, locale); //we don't expose the internal promo code - } - - List filteredTickets = tickets.stream().filter(ticket -> promoCodeDiscount.getCategories().contains(ticket.getCategoryId())).collect(toList()); - - if (promoCodeDiscount.getCategories().isEmpty() || filteredTickets.isEmpty()) { - return promoCodeDiscount.getPromoCode(); - } - - String formattedDiscountedCategories = filteredTickets.stream() - .map(Ticket::getCategoryId) - .collect(toSet()) - .stream() - .map(categoryId -> ticketCategoryRepository.getByIdAndActive(categoryId, promoCodeDiscount.getEventId()).getName()) - .collect(Collectors.joining(", ", "(", ")")); - - - return promoCodeDiscount.getPromoCode() + " " + formattedDiscountedCategories; + return reservationCostCalculator.totalReservationCostWithVAT(reservation); } public OrderSummary orderSummaryForReservationId(String reservationId, PurchaseContext purchaseContext) { - TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); - return orderSummaryForReservation(reservation, purchaseContext); + return orderSummaryGenerator.orderSummaryForReservationId(reservationId, purchaseContext); } public OrderSummary orderSummaryForReservation(TicketReservation reservation, PurchaseContext context) { - var totalPriceAndDiscount = totalReservationCostWithVAT(reservation); - TotalPrice reservationCost = totalPriceAndDiscount.getLeft(); - PromoCodeDiscount discount = totalPriceAndDiscount.getRight().orElse(null); - // - boolean free = reservationCost.getPriceWithVAT() == 0; - String refundedAmount = null; - - boolean hasRefund = auditingRepository.countAuditsOfTypeForReservation(reservation.getId(), Audit.EventType.REFUND) > 0; - - if(hasRefund) { - refundedAmount = paymentManager.getInfo(reservation, context).getPaymentInformation().getRefundedAmount(); - } - - var currencyCode = reservation.getCurrencyCode(); - return new OrderSummary(reservationCost, - extractSummary(reservation.getId(), reservation.getVatStatus(), context, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), discount, reservationCost), - free, - formatCents(reservationCost.getPriceWithVAT(), currencyCode), - formatCents(reservationCost.getVAT(), currencyCode), - reservation.getStatus() == TicketReservationStatus.OFFLINE_PAYMENT, - reservation.getStatus() == DEFERRED_OFFLINE_PAYMENT, - reservation.getPaymentMethod() == PaymentProxy.ON_SITE, - Optional.ofNullable(context.getVat()).map(p -> MonetaryUtil.formatCents(MonetaryUtil.unitToCents(p, currencyCode), currencyCode)).orElse(null), - reservation.getVatStatus(), - refundedAmount); - } - - private OrderSummary orderSummaryForCreditNote(TicketReservation reservation, PurchaseContext purchaseContext, List removedTickets) { - var totalPriceAndDiscount = totalReservationCostWithVAT(null, purchaseContext, reservation, removedTickets, List.of(), List.of(), Optional.empty()); - TotalPrice reservationCost = totalPriceAndDiscount.getLeft(); - // - boolean free = reservationCost.getPriceWithVAT() == 0; - - var currencyCode = reservation.getCurrencyCode(); - return new OrderSummary(reservationCost, - extractSummary(reservation.getVatStatus(), purchaseContext, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), null, reservationCost, removedTickets, Stream.empty(), subscriptionRepository.findSubscriptionsByReservationId(reservation.getId())), - free, - formatCents(reservationCost.getPriceWithVAT(), currencyCode), - formatCents(reservationCost.getVAT(), currencyCode), - reservation.getStatus() == TicketReservationStatus.OFFLINE_PAYMENT, - reservation.getStatus() == DEFERRED_OFFLINE_PAYMENT, - reservation.getPaymentMethod() == PaymentProxy.ON_SITE, - Optional.ofNullable(purchaseContext.getVat()).map(p -> MonetaryUtil.formatCents(MonetaryUtil.unitToCents(p, currencyCode), currencyCode)).orElse(null), - reservation.getVatStatus(), - null); - } - - List extractSummary(VatStatus reservationVatStatus, - PurchaseContext purchaseContext, - Locale locale, - PromoCodeDiscount promoCodeDiscount, - TotalPrice reservationCost, - List ticketsToInclude, - Stream>> additionalServicesToInclude, - List subscriptionsToInclude) { - log.trace("extract summary subscriptionsToInclude {}", subscriptionsToInclude); - List summary = new ArrayList<>(); - var currencyCode = reservationCost.getCurrencyCode(); - List tickets = ticketsToInclude.stream() - .map(t -> TicketPriceContainer.from(t, reservationVatStatus, purchaseContext.getVat(), purchaseContext.getVatStatus(), promoCodeDiscount)).collect(toList()); - purchaseContext.event().ifPresent(event -> { - boolean multipleTaxRates = tickets.stream().map(TicketPriceContainer::getVatStatus).collect(Collectors.toSet()).size() > 1; - var ticketsByCategory = tickets.stream() - .collect(Collectors.groupingBy(TicketPriceContainer::getCategoryId)); - List>> sorted; - if (multipleTaxRates) { - sorted = ticketsByCategory - .entrySet() - .stream() - .sorted(Comparator.comparing((Entry> e) -> e.getValue().get(0).getVatStatus()).reversed()) - .collect(Collectors.toList()); - } else { - sorted = new ArrayList<>(ticketsByCategory.entrySet()); - } - Map categoriesById; - - if(ticketsByCategory.isEmpty()) { - categoriesById = Map.of(); - } else { - categoriesById = ticketCategoryRepository.getByIdsAndActive(ticketsByCategory.keySet(), event.getId()) - .stream() - .collect(Collectors.toMap(TicketCategory::getId, Function.identity())); - } - - for (var categoryWithTickets : sorted) { - var categoryTickets = categoryWithTickets.getValue(); - final int subTotal = categoryTickets.stream().mapToInt(TicketPriceContainer::getSummarySrcPriceCts).sum(); - final int subTotalBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(categoryTickets); - var firstTicket = categoryTickets.get(0); - final int ticketPriceCts = firstTicket.getSummarySrcPriceCts(); - final int priceBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(singletonList(firstTicket)); - String categoryName = categoriesById.get(categoryWithTickets.getKey()).getName(); - var ticketVatStatus = firstTicket.getVatStatus(); - summary.add(new SummaryRow(categoryName, formatCents(ticketPriceCts, currencyCode), formatCents(priceBeforeVat, currencyCode), categoryTickets.size(), formatCents(subTotal, currencyCode), formatCents(subTotalBeforeVat, currencyCode), subTotal, SummaryType.TICKET, null, ticketVatStatus)); - if (VatStatus.isVatExempt(ticketVatStatus) && ticketVatStatus != reservationVatStatus) { - summary.add(new SummaryRow(null, - "", - "", - 0, - formatCents(0, currencyCode, true), - formatCents(0, currencyCode, true), - 0, - SummaryType.TAX_DETAIL, - "0", ticketVatStatus)); - } - } - }); - - summary.addAll(additionalServicesToInclude - .map(entry -> { - String language = locale.getLanguage(); - AdditionalServiceText title = additionalServiceTextRepository.findBestMatchByLocaleAndType(entry.getKey().getId(), language, AdditionalServiceText.TextType.TITLE); - if(!title.getLocale().equals(language) || title.getId() == -1) { - log.debug("additional service {}: title not found for locale {}", title.getAdditionalServiceId(), language); - } - List prices = generateASIPriceContainers(purchaseContext, null).apply(entry).collect(toList()); - AdditionalServiceItemPriceContainer first = prices.get(0); - final int subtotal = prices.stream().mapToInt(AdditionalServiceItemPriceContainer::getSrcPriceCts).sum(); - final int subtotalBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(prices); - return new SummaryRow(title.getValue(), formatCents(first.getSrcPriceCts(), currencyCode), formatCents(SummaryPriceContainer.getSummaryPriceBeforeVatCts(singletonList(first)), currencyCode), prices.size(), formatCents(subtotal, currencyCode), formatCents(subtotalBeforeVat, currencyCode), subtotal, SummaryType.ADDITIONAL_SERVICE, null, first.getVatStatus()); - }).collect(toList())); - - Optional.ofNullable(promoCodeDiscount).ifPresent(promo -> { - String formattedSingleAmount = "-" + (DiscountType.isFixedAmount(promo.getDiscountType()) ? formatCents(promo.getDiscountAmount(), currencyCode) : (promo.getDiscountAmount()+"%")); - summary.add(new SummaryRow(formatPromoCode(promo, ticketsToInclude, locale, purchaseContext), - formattedSingleAmount, - formattedSingleAmount, - reservationCost.getDiscountAppliedCount(), - formatCents(reservationCost.getDiscount(), currencyCode), formatCents(reservationCost.getDiscount(), currencyCode), reservationCost.getDiscount(), - promo.isDynamic() ? SummaryType.DYNAMIC_DISCOUNT : SummaryType.PROMOTION_CODE, - null, reservationVatStatus)); - }); - // - if(purchaseContext instanceof SubscriptionDescriptor) { - if(!subscriptionsToInclude.isEmpty()) { - var subscription = subscriptionsToInclude.get(0); - var priceContainer = new SubscriptionPriceContainer(subscription, promoCodeDiscount, (SubscriptionDescriptor) purchaseContext); - var priceBeforeVat = formatUnit(priceContainer.getNetPrice(), currencyCode); - summary.add(new SummaryRow(purchaseContext.getTitle().get(locale.getLanguage()), - formatCents(priceContainer.getSummarySrcPriceCts(), currencyCode), - priceBeforeVat, - subscriptionsToInclude.size(), - formatCents(priceContainer.getSummarySrcPriceCts() * subscriptionsToInclude.size(), currencyCode), - formatUnit(priceContainer.getNetPrice().multiply(new BigDecimal(subscriptionsToInclude.size())), currencyCode), - priceContainer.getSummarySrcPriceCts(), - SummaryType.SUBSCRIPTION, - null, - reservationVatStatus)); - } - } else if(CollectionUtils.isNotEmpty(subscriptionsToInclude)) { - log.trace("subscriptions to include is not empty"); - var subscription = subscriptionsToInclude.get(0); - subscriptionRepository.findOne(subscription.getSubscriptionDescriptorId(), subscription.getOrganizationId()).ifPresent(subscriptionDescriptor -> { - log.trace("found subscriptionDescriptor with ID {}", subscriptionDescriptor.getId()); - // find tickets with subscription applied - var ticketsSubscription = tickets.stream().filter(t -> Objects.equals(subscription.getId(), t.getSubscriptionId())).collect(toList()); - final int ticketPriceCts = ticketsSubscription.stream().mapToInt(TicketPriceContainer::getSummarySrcPriceCts).sum(); - final int priceBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(ticketsSubscription); - summary.add(new SummaryRow(subscriptionDescriptor.getLocalizedTitle(locale), - "-" + formatCents(ticketPriceCts, currencyCode), - "-" + formatCents(priceBeforeVat, currencyCode), - ticketsSubscription.size(), - "-" + formatCents(ticketPriceCts, currencyCode), - "-" + formatCents(priceBeforeVat, currencyCode), - ticketPriceCts, - SummaryType.APPLIED_SUBSCRIPTION, - null, - reservationVatStatus)); - }); - } - - // - return summary; - } - - List extractSummary(String reservationId, VatStatus reservationVatStatus, - PurchaseContext purchaseContext, Locale locale, PromoCodeDiscount promoCodeDiscount, TotalPrice reservationCost) { - List subscriptionsToInclude; - if(purchaseContext.ofType(PurchaseContextType.event)) { - subscriptionsToInclude = subscriptionRepository.findAppliedSubscriptionByReservationId(reservationId) - .map(List::of) - .orElse(List.of()); - } else { - subscriptionsToInclude = subscriptionRepository.findSubscriptionsByReservationId(reservationId); - } - - return extractSummary(reservationVatStatus, - purchaseContext, - locale, - promoCodeDiscount, - reservationCost, - ticketRepository.findTicketsInReservation(reservationId), - streamAdditionalServiceItems(reservationId, purchaseContext), - subscriptionsToInclude); - } - - private Stream>> streamAdditionalServiceItems(String reservationId, PurchaseContext purchaseContext) { - return purchaseContext.event().map(event -> { - return additionalServiceItemRepository.findByReservationUuid(reservationId) - .stream() - .collect(Collectors.groupingBy(AdditionalServiceItem::getAdditionalServiceId)) - .entrySet() - .stream() - .map(entry -> Pair.of(additionalServiceRepository.getById(entry.getKey(), event.getId()), entry.getValue())); - }).orElse(Stream.empty()); - } - private List>> collectAdditionalServiceItems(String reservationId, Event event) { - return streamAdditionalServiceItems(reservationId, event).collect(Collectors.toList()); + return orderSummaryGenerator.orderSummaryForReservation(reservation, context); } String reservationUrl(String reservationId) { @@ -1884,39 +1192,19 @@ public String reservationUrlForExternalClients(String reservationId, PurchaseCon return baseUrl + "/openid/authentication?reservation=" + reservationId + "&contextType=" + purchaseContext.getType() + "&id=" + purchaseContext.getPublicIdentifier(); } else { var cleanSubscriptionId = StringUtils.trimToNull(subscriptionId); - return reservationUrl(baseUrl, reservationId, purchaseContext, userLanguage, cleanSubscriptionId != null ? "subscription="+cleanSubscriptionId : null); + return ReservationUtil.reservationUrl(baseUrl, reservationId, purchaseContext, userLanguage, cleanSubscriptionId != null ? "subscription="+cleanSubscriptionId : null); } } - String reservationUrl(String baseUrl, String reservationId, PurchaseContext purchaseContext, String userLanguage, String additionalParams) { - var cleanParams = StringUtils.trimToNull(additionalParams); - return StringUtils.removeEnd(baseUrl, "/") - + "/" + purchaseContext.getType() - + "/" + purchaseContext.getPublicIdentifier() - + "/reservation/" + reservationId - + "?lang="+userLanguage - + (cleanParams != null ? "&" + cleanParams : ""); - } - String reservationUrl(String baseUrl, String reservationId, PurchaseContext purchaseContext, String userLanguage) { - return reservationUrl(baseUrl, reservationId, purchaseContext, userLanguage, null); - } - String reservationUrl(TicketReservation reservation, PurchaseContext purchaseContext) { - return reservationUrl(configurationManager.baseUrl(purchaseContext), reservation.getId(), purchaseContext, reservation.getUserLanguage()); + return ReservationUtil.reservationUrl(reservation, purchaseContext, configurationManager); } String ticketUrl(Event event, String ticketId) { Ticket ticket = ticketRepository.findByUUID(ticketId); - return configurationManager.baseUrl(event) + "/event/" + event.getShortName() + "/ticket/" + ticketId + "?lang=" + ticket.getUserLanguage(); } - public String ticketUpdateUrl(Event event, String ticketId) { - Ticket ticket = ticketRepository.findByUUID(ticketId); - - return configurationManager.baseUrl(event) + "/event/" + event.getShortName() + "/ticket/" + ticketId + "/update?lang=" + ticket.getUserLanguage(); - } - public String ticketOnlineCheckIn(Event event, String ticketId) { Ticket ticket = ticketRepository.findByUUID(ticketId); @@ -2033,10 +1321,6 @@ public Optional findFirstInReservation(String reservationId) { return ticketRepository.findFirstTicketInReservation(reservationId); } - public Optional getVAT(PurchaseContext purchaseContext) { - return configurationManager.getFor(VAT_NR, purchaseContext.getConfigurationLevel()).getValue(); - } - public void updateTicketOwner(Ticket ticket, Locale locale, Event event, @@ -2067,7 +1351,7 @@ public void updateTicketOwner(Ticket ticket, boolean sendTicketAllowed = configurationManager.getFor(SEND_TICKETS_AUTOMATICALLY, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); if (sendTicketAllowed && (newTicket.getStatus() == TicketStatus.ACQUIRED || newTicket.getStatus() == TicketStatus.TO_BE_PAID) && (!equalsIgnoreCase(newEmail, ticket.getEmail()) || !equalsIgnoreCase(customerName.getFullName(), ticket.getFullName()))) { - sendTicketByEmail(newTicket, userLocale, event, confirmationTextBuilder); + reservationHelper.sendTicketByEmail(newTicket, userLocale, event, confirmationTextBuilder); } boolean admin = isAdmin(userDetails); @@ -2096,7 +1380,7 @@ public void updateTicketOwner(Ticket ticket, Ticket postUpdateTicket = ticketRepository.findByUUID(ticket.getUuid()); Map postUpdateTicketFields = ticketFieldRepository.findAllByTicketId(ticket.getId()).stream().collect(Collectors.toMap(TicketFieldValue::getName, TicketFieldValue::getValue)); - auditUpdateTicket(preUpdateTicket, preUpdateTicketFields, postUpdateTicket, postUpdateTicketFields, event.getId()); + auditingHelper.auditUpdateTicket(preUpdateTicket, preUpdateTicketFields, postUpdateTicket, postUpdateTicketFields, event.getId()); } boolean isTicketBeingReassigned(Ticket original, UpdateTicketOwnerForm updated, Event event) { @@ -2108,50 +1392,12 @@ boolean isTicketBeingReassigned(Ticket original, UpdateTicketOwnerForm updated, && (!equalsIgnoreCase(original.getEmail(), updated.getEmail()) || !equalsIgnoreCase(original.getFullName(), customerName.getFullName())); } - private void auditUpdateMetadata(String reservationId, - int ticketId, - int eventId, - TicketMetadataContainer newMetadata, - TicketMetadataContainer oldMetadata) { - List> changes = ObjectDiffUtil.diff(oldMetadata, newMetadata, TicketMetadataContainer.class).stream() - .map(this::processChange) - .collect(Collectors.toList()); - - auditingRepository.insert(reservationId, null, eventId, Audit.EventType.UPDATE_TICKET_METADATA, new Date(), - TICKET, Integer.toString(ticketId), changes); - } - - private void auditUpdateTicket(Ticket preUpdateTicket, Map preUpdateTicketFields, Ticket postUpdateTicket, Map postUpdateTicketFields, int eventId) { - List diffTicket = ObjectDiffUtil.diff(preUpdateTicket, postUpdateTicket); - List diffTicketFields = ObjectDiffUtil.diff(preUpdateTicketFields, postUpdateTicketFields); - - List> changes = Stream.concat(diffTicket.stream(), diffTicketFields.stream()) - .map(this::processChange) - .collect(Collectors.toList()); - auditingRepository.insert(preUpdateTicket.getTicketsReservationId(), null, eventId, - Audit.EventType.UPDATE_TICKET, new Date(), TICKET, Integer.toString(preUpdateTicket.getId()), changes); - } - - private HashMap processChange(ObjectDiffUtil.Change change) { - var v = new HashMap(); - v.put("propertyName", change.getPropertyName()); - v.put("state", change.getState()); - v.put("oldValue", change.getOldValue()); - v.put("newValue", change.getNewValue()); - return v; - } private boolean isAdmin(Optional userDetails) { return userDetails.flatMap(u -> u.getAuthorities().stream().map(a -> Role.fromRoleName(a.getAuthority())).filter(Role.ADMIN::equals).findFirst()).isPresent(); } - void sendTicketByEmail(Ticket ticket, Locale locale, Event event, PartialTicketTextGenerator confirmationTextBuilder) { - TicketReservation reservation = ticketReservationRepository.findReservationById(ticket.getTicketsReservationId()); - TicketCategory ticketCategory = ticketCategoryRepository.getByIdAndActive(ticket.getCategoryId(), event.getId()); - notificationManager.sendTicketByEmail(ticket, event, locale, confirmationTextBuilder, reservation, ticketCategory, () -> retrieveAttendeeAdditionalInfoForTicket(ticket)); - } - public Optional> fetchComplete(String eventName, String ticketIdentifier) { return ticketRepository.findOptionalByUUID(ticketIdentifier) .flatMap(ticket -> from(eventName, ticket.getTicketsReservationId(), ticketIdentifier) @@ -2203,11 +1449,11 @@ public void sendReminderForOfflinePayments() { .forEach(p -> { TicketReservation reservation = p.getLeft(); Event event = p.getMiddle(); - Map model = prepareModelForReservationEmail(event, reservation); + Map model = reservationHelper.prepareModelForReservationEmail(event, reservation); Locale locale = p.getRight(); ticketReservationRepository.flagAsOfflinePaymentReminderSent(reservation.getId()); notificationManager.sendSimpleEmail(event, reservation.getId(), reservation.getEmail(), messageSourceManager.getMessageSourceFor(event).getMessage("reservation.reminder.mail.subject", - new Object[]{getShortReservationID(event, reservation)}, locale), () -> templateManager.renderTemplate(event, TemplateResource.REMINDER_EMAIL, model, locale)); + new Object[]{configurationManager.getShortReservationID(event, reservation)}, locale), () -> templateManager.renderTemplate(event, TemplateResource.REMINDER_EMAIL, model, locale)); }); } @@ -2261,7 +1507,7 @@ private void sendOptionalDataReminder(Pair> eventAndTickets) .forEach(t -> { int result = ticketRepository.flagTicketAsReminderSent(t.getId()); Validate.isTrue(result == 1); - Map model = TemplateResource.prepareModelForReminderTicketAdditionalInfo(organizationRepository.getById(event.getOrganizationId()), event, t, ticketUpdateUrl(event, t.getUuid())); + Map model = TemplateResource.prepareModelForReminderTicketAdditionalInfo(organizationRepository.getById(event.getOrganizationId()), event, t, ReservationUtil.ticketUpdateUrl(event, t, configurationManager)); Locale locale = Optional.ofNullable(t.getUserLanguage()).map(LocaleUtil::forLanguageTag).orElseGet(() -> findReservationLanguage(t.getTicketsReservationId())); notificationManager.sendSimpleEmail(event, t.getTicketsReservationId(), t.getEmail(), messageSource.getMessage("reminder.ticket-additional-info.subject", new Object[]{event.getDisplayName()}, locale), () -> templateManager.renderTemplate(event, TemplateResource.REMINDER_TICKET_ADDITIONAL_INFO, model, locale)); @@ -2291,7 +1537,7 @@ private void sendAssignmentReminder(Pair> p) { .filter(Optional::isPresent) .map(Optional::get) .forEach(reservation -> { - Map model = prepareModelForReservationEmail(event, reservation); + Map model = reservationHelper.prepareModelForReservationEmail(event, reservation); ticketReservationRepository.updateLatestReminderTimestamp(reservation.getId(), ZonedDateTime.now(clockProvider.withZone(eventZoneId))); Locale locale = findReservationLanguage(reservation.getId()); notificationManager.sendSimpleEmail(event, reservation.getId(), reservation.getEmail(), messageSource.getMessage("reminder.ticket-not-assigned.subject", @@ -2317,9 +1563,6 @@ public String getShortReservationID(Configurable event, String reservationId) { return configurationManager.getShortReservationID(event, findById(reservationId).orElseThrow()); } - public String getShortReservationID(Configurable event, TicketReservation reservation) { - return configurationManager.getShortReservationID(event, reservation); - } public int countAvailableTickets(EventAndOrganizationId event, TicketCategory category) { if(category.isBounded()) { @@ -2551,10 +1794,6 @@ public void updateReservationInvoicingAdditionalInformation(String reservationId ticketReservationRepository.updateInvoicingAdditionalInformation(reservationId, json.asJsonString(ticketReservationInvoicingAdditionalInfo)); } - private static Locale getReservationLocale(TicketReservation reservation) { - return StringUtils.isEmpty(reservation.getUserLanguage()) ? Locale.ENGLISH : LocaleUtil.forLanguageTag(reservation.getUserLanguage()); - } - public PaymentWebhookResult processTransactionWebhook(String body, String signature, PaymentProxy paymentProxy, Map additionalInfo) { return processTransactionWebhook(body, signature, paymentProxy, additionalInfo, new PaymentContext()); } @@ -2645,8 +1884,8 @@ private PaymentWebhookResult handlePaymentWebhookResult(PurchaseContext purchase var totalPrice = totalReservationCostWithVAT(reservation).getLeft(); var paymentToken = paymentWebhookResult.getPaymentToken(); var paymentSpecification = new PaymentSpecification(reservation, totalPrice, purchaseContext, paymentToken, - orderSummaryForReservation(reservation, purchaseContext), true, eventHasPrivacyPolicy(purchaseContext)); - transitionToComplete(paymentSpecification, totalPrice, paymentToken.getPaymentProvider(), null); + orderSummaryForReservation(reservation, purchaseContext), true, hasPrivacyPolicy(purchaseContext)); + transitionToComplete(paymentSpecification, paymentToken.getPaymentProvider(), null); break; } case FAILED: { @@ -2654,7 +1893,7 @@ private PaymentWebhookResult handlePaymentWebhookResult(PurchaseContext purchase // depending on when we actually receive the event, we could have two possibilities: // // 1) the user is still waiting on the payment page. In this case, there's no harm in reverting the reservation status to PENDING - // 2) the user has given up and we're officially in background mode. + // 2) the user has given up, and we're officially in background mode. // // either way, we have to notify the user about the charge failure. Then: // @@ -2738,7 +1977,7 @@ private boolean reservationStatusNotCompatible(TicketReservation reservation) { } private void sendTransactionFailedEmail(PurchaseContext purchaseContext, TicketReservation reservation, PaymentMethod paymentMethod, PaymentWebhookResult paymentWebhookResult, boolean cancelReservation) { - var shortReservationID = getShortReservationID(purchaseContext, reservation); + var shortReservationID = configurationManager.getShortReservationID(purchaseContext, reservation); var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); Map model = Map.of( ORGANIZATION, organizationRepository.getById(purchaseContext.getOrganizationId()), @@ -2746,7 +1985,7 @@ private void sendTransactionFailedEmail(PurchaseContext purchaseContext, TicketR "reservation", reservation, RESERVATION_ID, shortReservationID, "eventName", purchaseContext.getDisplayName(), - "provider", requireNonNullElse(paymentMethod.name(), ""), + "provider", requireNonNullElse(paymentMethod, PaymentMethod.NONE).name(), "reason", paymentWebhookResult.getReason(), "reservationUrl", reservationUrl(reservation, purchaseContext)); @@ -2853,13 +2092,13 @@ public Optional createTicketReservation(Event event, false, principal); return Optional.of(reservationId); - } catch (TicketReservationManager.NotEnoughTicketsException nete) { + } catch (NotEnoughTicketsException nete) { bindingResult.reject(ErrorsCode.STEP_1_NOT_ENOUGH_TICKETS); - } catch (TicketReservationManager.MissingSpecialPriceTokenException missing) { + } catch (MissingSpecialPriceTokenException missing) { bindingResult.reject(ErrorsCode.STEP_1_ACCESS_RESTRICTED); - } catch (TicketReservationManager.InvalidSpecialPriceTokenException invalid) { + } catch (InvalidSpecialPriceTokenException invalid) { bindingResult.reject(ErrorsCode.STEP_1_CODE_NOT_FOUND); - } catch (TicketReservationManager.TooManyTicketsForDiscountCodeException tooMany) { + } catch (TooManyTicketsForDiscountCodeException tooMany) { bindingResult.reject(ErrorsCode.STEP_2_DISCOUNT_CODE_USAGE_EXCEEDED); } catch (CannotProceedWithPayment cannotProceedWithPayment) { bindingResult.reject(ErrorsCode.STEP_1_CATEGORIES_NOT_COMPATIBLE); @@ -3179,9 +2418,7 @@ public Optional findSubscriptionDetails(TicketRese } public Map> retrieveAttendeeAdditionalInfoForTicket(Ticket ticket) { - return ticketFieldRepository.findNameAndValue(ticket.getId()) - .stream() - .collect(groupingBy(FieldNameAndValue::getName, mapping(FieldNameAndValue::getValue, toList()))); + return reservationHelper.retrieveAttendeeAdditionalInfoForTicket(ticket); } private Integer retrievePublicUserId(Principal principal) { diff --git a/src/main/java/alfio/manager/support/CustomMessageManager.java b/src/main/java/alfio/manager/support/CustomMessageManager.java index 6172bd9f9c..9cd3faebfe 100644 --- a/src/main/java/alfio/manager/support/CustomMessageManager.java +++ b/src/main/java/alfio/manager/support/CustomMessageManager.java @@ -29,10 +29,7 @@ import alfio.repository.EventRepository; import alfio.repository.TicketCategoryRepository; import alfio.repository.TicketRepository; -import alfio.util.EventUtil; -import alfio.util.Json; -import alfio.util.RenderedTemplate; -import alfio.util.TemplateManager; +import alfio.util.*; import alfio.util.checkin.TicketCheckInUtil; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -97,7 +94,7 @@ public void sendMessages(String eventName, Optional categoryId, List. + */ +package alfio.manager.support; + +import alfio.model.TicketReservation.TicketReservationStatus; +import alfio.model.system.command.FinalizeReservation; +import alfio.model.transaction.PaymentProxy; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RetryFinalizeReservation { + + private final String reservationId; + private final PaymentProxy paymentProxy; + private final boolean sendReservationConfirmationEmail; + private final boolean sendTickets; + private final String username; + private final boolean tcAccepted; + private final boolean privacyPolicyAccepted; + private final TicketReservationStatus originalStatus; + + @JsonCreator + public RetryFinalizeReservation(@JsonProperty("reservationId") String reservationId, + @JsonProperty("paymentProxy") PaymentProxy paymentProxy, + @JsonProperty("sendReservationConfirmationEmail") boolean sendReservationConfirmationEmail, + @JsonProperty("sendTickets") boolean sendTickets, + @JsonProperty("username") String username, + @JsonProperty("tcAccepted") boolean tcAccepted, + @JsonProperty("privacyPolicyAccepted") boolean privacyPolicyAccepted, + @JsonProperty("originalStatus") TicketReservationStatus originalStatus) { + this.reservationId = reservationId; + this.paymentProxy = paymentProxy; + this.sendReservationConfirmationEmail = sendReservationConfirmationEmail; + this.sendTickets = sendTickets; + this.username = username; + this.tcAccepted = tcAccepted; + this.privacyPolicyAccepted = privacyPolicyAccepted; + this.originalStatus = originalStatus; + } + + public String getReservationId() { + return reservationId; + } + + public PaymentProxy getPaymentProxy() { + return paymentProxy; + } + + public boolean isSendReservationConfirmationEmail() { + return sendReservationConfirmationEmail; + } + + public boolean isSendTickets() { + return sendTickets; + } + + public String getUsername() { + return username; + } + + public boolean isTcAccepted() { + return tcAccepted; + } + + public boolean isPrivacyPolicyAccepted() { + return privacyPolicyAccepted; + } + + public TicketReservationStatus getOriginalStatus() { + return originalStatus; + } + + public static RetryFinalizeReservation fromFinalizeReservation(FinalizeReservation finalizeReservation) { + var paymentSpecification = finalizeReservation.getPaymentSpecification(); + return new RetryFinalizeReservation(paymentSpecification.getReservationId(), + finalizeReservation.getPaymentProxy(), + finalizeReservation.isSendReservationConfirmationEmail(), + finalizeReservation.isSendTickets(), + finalizeReservation.getUsername(), + paymentSpecification.isTcAccepted(), + paymentSpecification.isPrivacyAccepted(), + finalizeReservation.getOriginalStatus() + ); + } +} diff --git a/src/main/java/alfio/manager/support/TemplateGenerator.java b/src/main/java/alfio/manager/support/TemplateGenerator.java index 680c793547..a0a256f89b 100644 --- a/src/main/java/alfio/manager/support/TemplateGenerator.java +++ b/src/main/java/alfio/manager/support/TemplateGenerator.java @@ -17,7 +17,7 @@ package alfio.manager.support; import alfio.util.RenderedTemplate; - +@FunctionalInterface public interface TemplateGenerator { RenderedTemplate generate(); } diff --git a/src/main/java/alfio/manager/support/reservation/CannotProceedWithPayment.java b/src/main/java/alfio/manager/support/reservation/CannotProceedWithPayment.java new file mode 100644 index 0000000000..77cdf62fac --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/CannotProceedWithPayment.java @@ -0,0 +1,23 @@ +/** + * 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.manager.support.reservation; + +public class CannotProceedWithPayment extends RuntimeException { + public CannotProceedWithPayment(String message) { + super(message); + } +} diff --git a/src/main/java/alfio/manager/support/reservation/InvalidSpecialPriceTokenException.java b/src/main/java/alfio/manager/support/reservation/InvalidSpecialPriceTokenException.java new file mode 100644 index 0000000000..0a8cb122bb --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/InvalidSpecialPriceTokenException.java @@ -0,0 +1,21 @@ +/** + * 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.manager.support.reservation; + +public class InvalidSpecialPriceTokenException extends RuntimeException { + +} diff --git a/src/main/java/alfio/manager/support/reservation/MissingSpecialPriceTokenException.java b/src/main/java/alfio/manager/support/reservation/MissingSpecialPriceTokenException.java new file mode 100644 index 0000000000..b7af6448dd --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/MissingSpecialPriceTokenException.java @@ -0,0 +1,20 @@ +/** + * 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.manager.support.reservation; + +public class MissingSpecialPriceTokenException extends RuntimeException { +} diff --git a/src/main/java/alfio/manager/support/reservation/NotEnoughTicketsException.java b/src/main/java/alfio/manager/support/reservation/NotEnoughTicketsException.java new file mode 100644 index 0000000000..533c2d8e60 --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/NotEnoughTicketsException.java @@ -0,0 +1,21 @@ +/** + * 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.manager.support.reservation; + +public class NotEnoughTicketsException extends RuntimeException { + +} diff --git a/src/main/java/alfio/manager/support/reservation/OrderSummaryGenerator.java b/src/main/java/alfio/manager/support/reservation/OrderSummaryGenerator.java new file mode 100644 index 0000000000..3788f76cb2 --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/OrderSummaryGenerator.java @@ -0,0 +1,312 @@ +/** + * 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.manager.support.reservation; + +import alfio.manager.PaymentManager; +import alfio.manager.i18n.MessageSourceManager; +import alfio.model.*; +import alfio.model.decorator.AdditionalServiceItemPriceContainer; +import alfio.model.decorator.TicketPriceContainer; +import alfio.model.subscription.Subscription; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.model.subscription.SubscriptionPriceContainer; +import alfio.model.transaction.PaymentProxy; +import alfio.repository.*; +import alfio.util.LocaleUtil; +import alfio.util.MonetaryUtil; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static alfio.manager.support.reservation.ReservationCostCalculator.totalReservationCostWithVAT; +import static alfio.model.TicketReservation.TicketReservationStatus.DEFERRED_OFFLINE_PAYMENT; +import static alfio.util.MonetaryUtil.formatCents; +import static alfio.util.MonetaryUtil.formatUnit; +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; + +@Component +public class OrderSummaryGenerator { + + private static final Logger log = LoggerFactory.getLogger(OrderSummaryGenerator.class); + private final TicketReservationRepository ticketReservationRepository; + private final AuditingRepository auditingRepository; + private final PaymentManager paymentManager; + private final TicketCategoryRepository ticketCategoryRepository; + private final AdditionalServiceTextRepository additionalServiceTextRepository; + private final SubscriptionRepository subscriptionRepository; + private final TicketRepository ticketRepository; + private final MessageSourceManager messageSourceManager; + private final ReservationCostCalculator reservationCostCalculator; + + public OrderSummaryGenerator(TicketReservationRepository ticketReservationRepository, + AuditingRepository auditingRepository, + PaymentManager paymentManager, + TicketCategoryRepository ticketCategoryRepository, + AdditionalServiceTextRepository additionalServiceTextRepository, + SubscriptionRepository subscriptionRepository, + TicketRepository ticketRepository, + MessageSourceManager messageSourceManager, + ReservationCostCalculator reservationCostCalculator) { + this.ticketReservationRepository = ticketReservationRepository; + this.auditingRepository = auditingRepository; + this.paymentManager = paymentManager; + this.ticketCategoryRepository = ticketCategoryRepository; + this.additionalServiceTextRepository = additionalServiceTextRepository; + this.subscriptionRepository = subscriptionRepository; + this.ticketRepository = ticketRepository; + this.messageSourceManager = messageSourceManager; + this.reservationCostCalculator = reservationCostCalculator; + } + + public OrderSummary orderSummaryForReservationId(String reservationId, PurchaseContext purchaseContext) { + TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); + return orderSummaryForReservation(reservation, purchaseContext); + } + public OrderSummary orderSummaryForReservation(TicketReservation reservation, PurchaseContext context) { + var totalPriceAndDiscount = reservationCostCalculator.totalReservationCostWithVAT(reservation); + TotalPrice reservationCost = totalPriceAndDiscount.getLeft(); + PromoCodeDiscount discount = totalPriceAndDiscount.getRight().orElse(null); + // + boolean free = reservationCost.getPriceWithVAT() == 0; + String refundedAmount = null; + + boolean hasRefund = auditingRepository.countAuditsOfTypeForReservation(reservation.getId(), Audit.EventType.REFUND) > 0; + + if(hasRefund) { + refundedAmount = paymentManager.getInfo(reservation, context).getPaymentInformation().getRefundedAmount(); + } + + var currencyCode = reservation.getCurrencyCode(); + return new OrderSummary(reservationCost, + extractSummary(reservation.getId(), reservation.getVatStatus(), context, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), discount, reservationCost), + free, + formatCents(reservationCost.getPriceWithVAT(), currencyCode), + formatCents(reservationCost.getVAT(), currencyCode), + reservation.getStatus() == TicketReservation.TicketReservationStatus.OFFLINE_PAYMENT, + reservation.getStatus() == DEFERRED_OFFLINE_PAYMENT, + reservation.getPaymentMethod() == PaymentProxy.ON_SITE, + Optional.ofNullable(context.getVat()).map(p -> MonetaryUtil.formatCents(MonetaryUtil.unitToCents(p, currencyCode), currencyCode)).orElse(null), + reservation.getVatStatus(), + refundedAmount); + } + + public OrderSummary orderSummaryForCreditNote(TicketReservation reservation, PurchaseContext purchaseContext, List removedTickets) { + var totalPriceAndDiscount = totalReservationCostWithVAT(null, purchaseContext, reservation, removedTickets, List.of(), List.of(), Optional.empty()); + TotalPrice reservationCost = totalPriceAndDiscount.getLeft(); + // + boolean free = reservationCost.getPriceWithVAT() == 0; + + var currencyCode = reservation.getCurrencyCode(); + return new OrderSummary(reservationCost, + extractSummary(reservation.getVatStatus(), purchaseContext, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), null, reservationCost, removedTickets, Stream.empty(), subscriptionRepository.findSubscriptionsByReservationId(reservation.getId())), + free, + formatCents(reservationCost.getPriceWithVAT(), currencyCode), + formatCents(reservationCost.getVAT(), currencyCode), + reservation.getStatus() == TicketReservation.TicketReservationStatus.OFFLINE_PAYMENT, + reservation.getStatus() == DEFERRED_OFFLINE_PAYMENT, + reservation.getPaymentMethod() == PaymentProxy.ON_SITE, + Optional.ofNullable(purchaseContext.getVat()).map(p -> MonetaryUtil.formatCents(MonetaryUtil.unitToCents(p, currencyCode), currencyCode)).orElse(null), + reservation.getVatStatus(), + null); + } + + List extractSummary(PriceContainer.VatStatus reservationVatStatus, + PurchaseContext purchaseContext, + Locale locale, + PromoCodeDiscount promoCodeDiscount, + TotalPrice reservationCost, + List ticketsToInclude, + Stream>> additionalServicesToInclude, + List subscriptionsToInclude) { + log.trace("extract summary subscriptionsToInclude {}", subscriptionsToInclude); + List summary = new ArrayList<>(); + var currencyCode = reservationCost.getCurrencyCode(); + List tickets = ticketsToInclude.stream() + .map(t -> TicketPriceContainer.from(t, reservationVatStatus, purchaseContext.getVat(), purchaseContext.getVatStatus(), promoCodeDiscount)).collect(toList()); + purchaseContext.event().ifPresent(event -> { + boolean multipleTaxRates = tickets.stream().map(TicketPriceContainer::getVatStatus).collect(Collectors.toSet()).size() > 1; + var ticketsByCategory = tickets.stream() + .collect(Collectors.groupingBy(TicketPriceContainer::getCategoryId)); + List>> sorted; + if (multipleTaxRates) { + sorted = ticketsByCategory + .entrySet() + .stream() + .sorted(Comparator.comparing((Map.Entry> e) -> e.getValue().get(0).getVatStatus()).reversed()) + .collect(Collectors.toList()); + } else { + sorted = new ArrayList<>(ticketsByCategory.entrySet()); + } + Map categoriesById; + + if(ticketsByCategory.isEmpty()) { + categoriesById = Map.of(); + } else { + categoriesById = ticketCategoryRepository.getByIdsAndActive(ticketsByCategory.keySet(), event.getId()) + .stream() + .collect(Collectors.toMap(TicketCategory::getId, Function.identity())); + } + + for (var categoryWithTickets : sorted) { + var categoryTickets = categoryWithTickets.getValue(); + final int subTotal = categoryTickets.stream().mapToInt(TicketPriceContainer::getSummarySrcPriceCts).sum(); + final int subTotalBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(categoryTickets); + var firstTicket = categoryTickets.get(0); + final int ticketPriceCts = firstTicket.getSummarySrcPriceCts(); + final int priceBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(singletonList(firstTicket)); + String categoryName = categoriesById.get(categoryWithTickets.getKey()).getName(); + var ticketVatStatus = firstTicket.getVatStatus(); + summary.add(new SummaryRow(categoryName, formatCents(ticketPriceCts, currencyCode), formatCents(priceBeforeVat, currencyCode), categoryTickets.size(), formatCents(subTotal, currencyCode), formatCents(subTotalBeforeVat, currencyCode), subTotal, SummaryRow.SummaryType.TICKET, null, ticketVatStatus)); + if (PriceContainer.VatStatus.isVatExempt(ticketVatStatus) && ticketVatStatus != reservationVatStatus) { + summary.add(new SummaryRow(null, + "", + "", + 0, + formatCents(0, currencyCode, true), + formatCents(0, currencyCode, true), + 0, + SummaryRow.SummaryType.TAX_DETAIL, + "0", ticketVatStatus)); + } + } + }); + + summary.addAll(additionalServicesToInclude + .map(entry -> { + String language = locale.getLanguage(); + AdditionalServiceText title = additionalServiceTextRepository.findBestMatchByLocaleAndType(entry.getKey().getId(), language, AdditionalServiceText.TextType.TITLE); + if(!title.getLocale().equals(language) || title.getId() == -1) { + log.debug("additional service {}: title not found for locale {}", title.getAdditionalServiceId(), language); + } + List prices = generateASIPriceContainers(purchaseContext, null).apply(entry).collect(toList()); + AdditionalServiceItemPriceContainer first = prices.get(0); + final int subtotal = prices.stream().mapToInt(AdditionalServiceItemPriceContainer::getSrcPriceCts).sum(); + final int subtotalBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(prices); + return new SummaryRow(title.getValue(), formatCents(first.getSrcPriceCts(), currencyCode), formatCents(SummaryPriceContainer.getSummaryPriceBeforeVatCts(singletonList(first)), currencyCode), prices.size(), formatCents(subtotal, currencyCode), formatCents(subtotalBeforeVat, currencyCode), subtotal, SummaryRow.SummaryType.ADDITIONAL_SERVICE, null, first.getVatStatus()); + }).collect(toList())); + + Optional.ofNullable(promoCodeDiscount).ifPresent(promo -> { + String formattedSingleAmount = "-" + (PromoCodeDiscount.DiscountType.isFixedAmount(promo.getDiscountType()) ? formatCents(promo.getDiscountAmount(), currencyCode) : (promo.getDiscountAmount()+"%")); + summary.add(new SummaryRow(formatPromoCode(promo, ticketsToInclude, locale, purchaseContext), + formattedSingleAmount, + formattedSingleAmount, + reservationCost.getDiscountAppliedCount(), + formatCents(reservationCost.getDiscount(), currencyCode), formatCents(reservationCost.getDiscount(), currencyCode), reservationCost.getDiscount(), + promo.isDynamic() ? SummaryRow.SummaryType.DYNAMIC_DISCOUNT : SummaryRow.SummaryType.PROMOTION_CODE, + null, reservationVatStatus)); + }); + // + if(purchaseContext instanceof SubscriptionDescriptor) { + if(!subscriptionsToInclude.isEmpty()) { + var subscription = subscriptionsToInclude.get(0); + var priceContainer = new SubscriptionPriceContainer(subscription, promoCodeDiscount, (SubscriptionDescriptor) purchaseContext); + var priceBeforeVat = formatUnit(priceContainer.getNetPrice(), currencyCode); + summary.add(new SummaryRow(purchaseContext.getTitle().get(locale.getLanguage()), + formatCents(priceContainer.getSummarySrcPriceCts(), currencyCode), + priceBeforeVat, + subscriptionsToInclude.size(), + formatCents(priceContainer.getSummarySrcPriceCts() * subscriptionsToInclude.size(), currencyCode), + formatUnit(priceContainer.getNetPrice().multiply(new BigDecimal(subscriptionsToInclude.size())), currencyCode), + priceContainer.getSummarySrcPriceCts(), + SummaryRow.SummaryType.SUBSCRIPTION, + null, + reservationVatStatus)); + } + } else if(CollectionUtils.isNotEmpty(subscriptionsToInclude)) { + log.trace("subscriptions to include is not empty"); + var subscription = subscriptionsToInclude.get(0); + subscriptionRepository.findOne(subscription.getSubscriptionDescriptorId(), subscription.getOrganizationId()).ifPresent(subscriptionDescriptor -> { + log.trace("found subscriptionDescriptor with ID {}", subscriptionDescriptor.getId()); + // find tickets with subscription applied + var ticketsSubscription = tickets.stream().filter(t -> Objects.equals(subscription.getId(), t.getSubscriptionId())).collect(toList()); + final int ticketPriceCts = ticketsSubscription.stream().mapToInt(TicketPriceContainer::getSummarySrcPriceCts).sum(); + final int priceBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(ticketsSubscription); + summary.add(new SummaryRow(subscriptionDescriptor.getLocalizedTitle(locale), + "-" + formatCents(ticketPriceCts, currencyCode), + "-" + formatCents(priceBeforeVat, currencyCode), + ticketsSubscription.size(), + "-" + formatCents(ticketPriceCts, currencyCode), + "-" + formatCents(priceBeforeVat, currencyCode), + ticketPriceCts, + SummaryRow.SummaryType.APPLIED_SUBSCRIPTION, + null, + reservationVatStatus)); + }); + } + + // + return summary; + } + + public List extractSummary(String reservationId, PriceContainer.VatStatus reservationVatStatus, + PurchaseContext purchaseContext, Locale locale, PromoCodeDiscount promoCodeDiscount, TotalPrice reservationCost) { + List subscriptionsToInclude; + if(purchaseContext.ofType(PurchaseContext.PurchaseContextType.event)) { + subscriptionsToInclude = subscriptionRepository.findAppliedSubscriptionByReservationId(reservationId) + .map(List::of) + .orElse(List.of()); + } else { + subscriptionsToInclude = subscriptionRepository.findSubscriptionsByReservationId(reservationId); + } + + return extractSummary(reservationVatStatus, + purchaseContext, + locale, + promoCodeDiscount, + reservationCost, + ticketRepository.findTicketsInReservation(reservationId), + reservationCostCalculator.streamAdditionalServiceItems(reservationId, purchaseContext), + subscriptionsToInclude); + } + + private String formatPromoCode(PromoCodeDiscount promoCodeDiscount, List tickets, Locale locale, PurchaseContext purchaseContext) { + + if(promoCodeDiscount.getCodeType() == PromoCodeDiscount.CodeType.DYNAMIC) { + return messageSourceManager.getMessageSourceFor(purchaseContext).getMessage("reservation.dynamic.discount.description", null, locale); //we don't expose the internal promo code + } + + List filteredTickets = tickets.stream().filter(ticket -> promoCodeDiscount.getCategories().contains(ticket.getCategoryId())).collect(toList()); + + if (promoCodeDiscount.getCategories().isEmpty() || filteredTickets.isEmpty()) { + return promoCodeDiscount.getPromoCode(); + } + + String formattedDiscountedCategories = filteredTickets.stream() + .map(Ticket::getCategoryId) + .collect(toSet()) + .stream() + .map(categoryId -> ticketCategoryRepository.getByIdAndActive(categoryId, promoCodeDiscount.getEventId()).getName()) + .collect(Collectors.joining(", ", "(", ")")); + + + return promoCodeDiscount.getPromoCode() + " " + formattedDiscountedCategories; + } + + private static Function>, Stream> generateASIPriceContainers(PurchaseContext purchaseContext, PromoCodeDiscount discount) { + return p -> p.getValue().stream().map(asi -> AdditionalServiceItemPriceContainer.from(asi, p.getKey(), purchaseContext, discount)); + } +} diff --git a/src/main/java/alfio/manager/support/reservation/ReservationAuditingHelper.java b/src/main/java/alfio/manager/support/reservation/ReservationAuditingHelper.java new file mode 100644 index 0000000000..31519040fb --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/ReservationAuditingHelper.java @@ -0,0 +1,75 @@ +/** + * 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.manager.support.reservation; + +import alfio.model.Audit; +import alfio.model.Ticket; +import alfio.model.metadata.TicketMetadataContainer; +import alfio.repository.AuditingRepository; +import alfio.util.ObjectDiffUtil; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static alfio.model.Audit.EntityType.TICKET; + +public class ReservationAuditingHelper { + + private final AuditingRepository auditingRepository; + + public ReservationAuditingHelper(AuditingRepository auditingRepository) { + this.auditingRepository = auditingRepository; + } + + public void auditUpdateMetadata(String reservationId, + int ticketId, + int eventId, + TicketMetadataContainer newMetadata, + TicketMetadataContainer oldMetadata) { + List> changes = ObjectDiffUtil.diff(oldMetadata, newMetadata, TicketMetadataContainer.class).stream() + .map(this::processChange) + .collect(Collectors.toList()); + + auditingRepository.insert(reservationId, null, eventId, Audit.EventType.UPDATE_TICKET_METADATA, new Date(), + TICKET, Integer.toString(ticketId), changes); + } + + public void auditUpdateTicket(Ticket preUpdateTicket, Map preUpdateTicketFields, Ticket postUpdateTicket, Map postUpdateTicketFields, int eventId) { + List diffTicket = ObjectDiffUtil.diff(preUpdateTicket, postUpdateTicket); + List diffTicketFields = ObjectDiffUtil.diff(preUpdateTicketFields, postUpdateTicketFields); + + List> changes = Stream.concat(diffTicket.stream(), diffTicketFields.stream()) + .map(this::processChange) + .collect(Collectors.toList()); + + auditingRepository.insert(preUpdateTicket.getTicketsReservationId(), null, eventId, + Audit.EventType.UPDATE_TICKET, new Date(), TICKET, Integer.toString(preUpdateTicket.getId()), changes); + } + + private HashMap processChange(ObjectDiffUtil.Change change) { + var v = new HashMap(); + v.put("propertyName", change.getPropertyName()); + v.put("state", change.getState()); + v.put("oldValue", change.getOldValue()); + v.put("newValue", change.getNewValue()); + return v; + } +} diff --git a/src/main/java/alfio/manager/support/reservation/ReservationCostCalculator.java b/src/main/java/alfio/manager/support/reservation/ReservationCostCalculator.java new file mode 100644 index 0000000000..62b1b9f72f --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/ReservationCostCalculator.java @@ -0,0 +1,126 @@ +/** + * 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.manager.support.reservation; + +import alfio.manager.PurchaseContextManager; +import alfio.model.*; +import alfio.model.decorator.TicketPriceContainer; +import alfio.model.subscription.Subscription; +import alfio.repository.*; +import alfio.util.MonetaryUtil; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static alfio.util.MonetaryUtil.unitToCents; +import static java.util.stream.Collectors.toList; + +@Component +public class ReservationCostCalculator { + + private final TicketReservationRepository ticketReservationRepository; + private final PurchaseContextManager purchaseContextManager; + private final PromoCodeDiscountRepository promoCodeDiscountRepository; + private final SubscriptionRepository subscriptionRepository; + private final TicketRepository ticketRepository; + private final AdditionalServiceRepository additionalServiceRepository; + private final AdditionalServiceItemRepository additionalServiceItemRepository; + + public ReservationCostCalculator(TicketReservationRepository ticketReservationRepository, + PurchaseContextManager purchaseContextManager, + PromoCodeDiscountRepository promoCodeDiscountRepository, + SubscriptionRepository subscriptionRepository, + TicketRepository ticketRepository, + AdditionalServiceRepository additionalServiceRepository, + AdditionalServiceItemRepository additionalServiceItemRepository) { + this.ticketReservationRepository = ticketReservationRepository; + this.purchaseContextManager = purchaseContextManager; + this.promoCodeDiscountRepository = promoCodeDiscountRepository; + + this.subscriptionRepository = subscriptionRepository; + this.ticketRepository = ticketRepository; + this.additionalServiceRepository = additionalServiceRepository; + this.additionalServiceItemRepository = additionalServiceItemRepository; + } + + /** + * Get the total cost with VAT if it's not included in the ticket price. + * + * @param reservationId + * @return + */ + public Pair> totalReservationCostWithVAT(String reservationId) { + return totalReservationCostWithVAT(ticketReservationRepository.findReservationById(reservationId)); + } + + public Pair> totalReservationCostWithVAT(TicketReservation reservation) { + return totalReservationCostWithVAT(purchaseContextManager.findByReservationId(reservation.getId()).orElseThrow(), reservation, ticketRepository.findTicketsInReservation(reservation.getId())); + } + + private Pair> totalReservationCostWithVAT(PurchaseContext purchaseContext, TicketReservation reservation, List tickets) { + var promoCodeDiscount = Optional.ofNullable(reservation.getPromoCodeDiscountId()).map(promoCodeDiscountRepository::findById); + var subscriptions = subscriptionRepository.findSubscriptionsByReservationId(reservation.getId()); + var appliedSubscription = subscriptionRepository.findAppliedSubscriptionByReservationId(reservation.getId()); + return totalReservationCostWithVAT(promoCodeDiscount.orElse(null), purchaseContext, reservation, tickets, + purchaseContext.event().map(event -> collectAdditionalServiceItems(reservation.getId(), event)).orElse(List.of()), + subscriptions, + appliedSubscription); + } + + public static Pair> totalReservationCostWithVAT(PromoCodeDiscount promoCodeDiscount, + PurchaseContext purchaseContext, + TicketReservation reservation, + List tickets, + List>> additionalServiceItems, + List subscriptions, + Optional appliedSubscription) { + + String currencyCode = purchaseContext.getCurrency(); + List ticketPrices = tickets.stream().map(t -> TicketPriceContainer.from(t, reservation.getVatStatus(), purchaseContext.getVat(), purchaseContext.getVatStatus(), promoCodeDiscount)).collect(toList()); + int discountedTickets = (int) ticketPrices.stream().filter(t -> t.getAppliedDiscount().compareTo(BigDecimal.ZERO) > 0).count(); + int discountAppliedCount = discountedTickets <= 1 || promoCodeDiscount.getDiscountType() == PromoCodeDiscount.DiscountType.FIXED_AMOUNT ? discountedTickets : 1; + if(discountAppliedCount == 0 && promoCodeDiscount != null && promoCodeDiscount.getDiscountType() == PromoCodeDiscount.DiscountType.FIXED_AMOUNT_RESERVATION) { + discountAppliedCount = 1; + } + var reservationPriceCalculator = alfio.manager.system.ReservationPriceCalculator.from(reservation, promoCodeDiscount, tickets, purchaseContext, additionalServiceItems, subscriptions, appliedSubscription); + var price = new TotalPrice(unitToCents(reservationPriceCalculator.getFinalPrice(), currencyCode), + unitToCents(reservationPriceCalculator.getVAT(), currencyCode), + -MonetaryUtil.unitToCents(reservationPriceCalculator.getAppliedDiscount(), currencyCode), + discountAppliedCount, + currencyCode); + return Pair.of(price, Optional.ofNullable(promoCodeDiscount)); + } + + Stream>> streamAdditionalServiceItems(String reservationId, PurchaseContext purchaseContext) { + return purchaseContext.event().map(event -> { + return additionalServiceItemRepository.findByReservationUuid(reservationId) + .stream() + .collect(Collectors.groupingBy(AdditionalServiceItem::getAdditionalServiceId)) + .entrySet() + .stream() + .map(entry -> Pair.of(additionalServiceRepository.getById(entry.getKey(), event.getId()), entry.getValue())); + }).orElse(Stream.empty()); + } + public List>> collectAdditionalServiceItems(String reservationId, Event event) { + return streamAdditionalServiceItems(reservationId, event).collect(Collectors.toList()); + } +} diff --git a/src/main/java/alfio/manager/support/reservation/ReservationEmailContentHelper.java b/src/main/java/alfio/manager/support/reservation/ReservationEmailContentHelper.java new file mode 100644 index 0000000000..a2c4100891 --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/ReservationEmailContentHelper.java @@ -0,0 +1,341 @@ +/** + * 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.manager.support.reservation; + +import alfio.controller.support.TemplateProcessor; +import alfio.manager.BillingDocumentManager; +import alfio.manager.ExtensionManager; +import alfio.manager.NotificationManager; +import alfio.manager.i18n.MessageSourceManager; +import alfio.manager.support.ConfirmationEmailConfiguration; +import alfio.manager.support.IncompatibleStateException; +import alfio.manager.support.PartialTicketTextGenerator; +import alfio.manager.system.ConfigurationLevel; +import alfio.manager.system.ConfigurationManager; +import alfio.manager.system.Mailer; +import alfio.model.*; +import alfio.model.extension.CustomEmailText; +import alfio.model.metadata.SubscriptionMetadata; +import alfio.model.subscription.Subscription; +import alfio.model.subscription.UsageDetails; +import alfio.model.system.ConfigurationKeys; +import alfio.model.user.Organization; +import alfio.repository.*; +import alfio.repository.user.OrganizationRepository; +import alfio.util.*; +import alfio.util.checkin.TicketCheckInUtil; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.*; + +import static alfio.model.BillingDocument.Type.INVOICE; +import static alfio.model.BillingDocument.Type.RECEIPT; +import static alfio.model.system.ConfigurationKeys.*; +import static alfio.util.ReservationUtil.reservationUrl; +import static java.util.stream.Collectors.*; +import static org.springframework.http.MediaType.APPLICATION_PDF; + +@Component +public class ReservationEmailContentHelper { + + private static final String RESERVATION_ID = "reservationId"; + private final ConfigurationManager configurationManager; + private final NotificationManager notificationManager; + private final SubscriptionRepository subscriptionRepository; + private final MessageSourceManager messageSourceManager; + private final OrderSummaryGenerator orderSummaryGenerator; + private final TicketReservationRepository ticketReservationRepository; + private final TicketCategoryRepository ticketCategoryRepository; + private final TicketFieldRepository ticketFieldRepository; + private final OrganizationRepository organizationRepository; + private final TicketRepository ticketRepository; + private final TemplateManager templateManager; + private final BillingDocumentManager billingDocumentManager; + private final ExtensionManager extensionManager; + private final EventRepository eventRepository; + + + public ReservationEmailContentHelper(ConfigurationManager configurationManager, + NotificationManager notificationManager, + SubscriptionRepository subscriptionRepository, + MessageSourceManager messageSourceManager, + OrderSummaryGenerator orderSummaryGenerator, + TicketReservationRepository ticketReservationRepository, + TicketCategoryRepository ticketCategoryRepository, + TicketFieldRepository ticketFieldRepository, + OrganizationRepository organizationRepository, + TicketRepository ticketRepository, + TemplateManager templateManager, + BillingDocumentManager billingDocumentManager, + ExtensionManager extensionManager, + EventRepository eventRepository) { + this.configurationManager = configurationManager; + this.notificationManager = notificationManager; + this.subscriptionRepository = subscriptionRepository; + this.messageSourceManager = messageSourceManager; + this.orderSummaryGenerator = orderSummaryGenerator; + this.ticketReservationRepository = ticketReservationRepository; + this.ticketCategoryRepository = ticketCategoryRepository; + this.ticketFieldRepository = ticketFieldRepository; + this.organizationRepository = organizationRepository; + this.ticketRepository = ticketRepository; + this.templateManager = templateManager; + this.billingDocumentManager = billingDocumentManager; + this.extensionManager = extensionManager; + this.eventRepository = eventRepository; + } + + + public void sendConfirmationEmail(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, String username) { + String reservationId = ticketReservation.getId(); + checkIfFinalized(reservationId); + OrderSummary summary = orderSummaryGenerator.orderSummaryForReservationId(reservationId, purchaseContext); + + List attachments; + if (configurationManager.canGenerateReceiptOrInvoiceToCustomer(purchaseContext)) { // https://github.com/alfio-event/alf.io/issues/573 + attachments = generateAttachmentForConfirmationEmail(purchaseContext, ticketReservation, language, summary, username); + } else{ + attachments = List.of(); + } + var vat = getVAT(purchaseContext); + + List configurations = new ArrayList<>(); + if(purchaseContext.ofType(PurchaseContext.PurchaseContextType.subscription)) { + var firstSubscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).stream().findFirst().orElseThrow(); + boolean sendSeparateEmailToOwner = !Objects.equals(firstSubscription.getEmail(), ticketReservation.getEmail()); + var metadata = Objects.requireNonNullElseGet(subscriptionRepository.getSubscriptionMetadata(firstSubscription.getId()), SubscriptionMetadata::empty); + Map initialModel = Map.of( + "pin", firstSubscription.getPin(), + "subscriptionId", firstSubscription.getId(), + "includePin", metadata.getConfiguration().isDisplayPin(), + "fullName", firstSubscription.getFirstName() + " " + firstSubscription.getLastName()); + var model = prepareModelForReservationEmail(purchaseContext, ticketReservation, vat, summary, List.of(), initialModel); + var subscriptionAttachments = new ArrayList<>(attachments); + subscriptionAttachments.add(generateSubscriptionAttachment(firstSubscription)); + configurations.add(new ConfirmationEmailConfiguration(TemplateResource.CONFIRMATION_EMAIL_SUBSCRIPTION, firstSubscription.getEmail(), model, sendSeparateEmailToOwner ? List.of() : subscriptionAttachments)); + if(sendSeparateEmailToOwner) { + var separateModel = new HashMap<>(model); + separateModel.put("includePin", false); + separateModel.put("fullName", ticketReservation.getFullName()); + configurations.add(new ConfirmationEmailConfiguration(TemplateResource.CONFIRMATION_EMAIL_SUBSCRIPTION, ticketReservation.getEmail(), separateModel, subscriptionAttachments)); + } + } else { + var model = prepareModelForReservationEmail(purchaseContext, ticketReservation, vat, summary, ticketRepository.findTicketsInReservation(ticketReservation.getId()), Map.of()); + configurations.add(new ConfirmationEmailConfiguration(TemplateResource.CONFIRMATION_EMAIL, ticketReservation.getEmail(), model, attachments)); + } + + var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); + var localizedType = messageSource.getMessage("purchase-context."+purchaseContext.getType(), null, language); + configurations.forEach(configuration -> { + notificationManager.sendSimpleEmail(purchaseContext, ticketReservation.getId(), configuration.getEmailAddress(), messageSource.getMessage("reservation-email-subject", + new Object[]{ configurationManager.getShortReservationID(purchaseContext, ticketReservation), purchaseContext.getTitle().get(language.getLanguage()), localizedType}, language), + () -> templateManager.renderTemplate(purchaseContext, configuration.getTemplateResource(), configuration.getModel(), language), + configuration.getAttachments()); + }); + } + + private Mailer.Attachment generateSubscriptionAttachment(Subscription subscription) { + var model = new HashMap(); + model.put("subscriptionId", subscription.getId().toString()); + return new Mailer.Attachment("subscription_" + subscription.getId() + ".pdf", null, APPLICATION_PDF.toString(), model, Mailer.AttachmentIdentifier.SUBSCRIPTION_PDF); + } + + private List generateAttachmentForConfirmationEmail(PurchaseContext purchaseContext, + TicketReservation ticketReservation, + Locale language, + OrderSummary summary, + String username) { + if(mustGenerateBillingDocument(summary, ticketReservation)) { //#459 - include PDF invoice in reservation email + BillingDocument.Type type = ticketReservation.getHasInvoiceNumber() ? INVOICE : RECEIPT; + return billingDocumentManager.generateBillingDocumentAttachment(purchaseContext, ticketReservation, language, type, username, summary); + } + return List.of(); + } + + public void sendReservationCompleteEmailToOrganizer(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, String username) { + String reservationId = ticketReservation.getId(); + + checkIfFinalized(reservationId); + + Organization organization = organizationRepository.getById(purchaseContext.getOrganizationId()); + List cc = notificationManager.getCCForEventOrganizer(purchaseContext); + + Map reservationEmailModel = prepareModelForReservationEmail(purchaseContext, ticketReservation); + + OrderSummary summary = orderSummaryGenerator.orderSummaryForReservationId(reservationId, purchaseContext); + + List attachments = Collections.emptyList(); + + if (!configurationManager.canGenerateReceiptOrInvoiceToCustomer(purchaseContext) || configurationManager.isInvoiceOnly(purchaseContext)) { // https://github.com/alfio-event/alf.io/issues/573 + attachments = generateAttachmentForConfirmationEmail(purchaseContext, ticketReservation, language, summary, username); + } + + + String shortReservationID = configurationManager.getShortReservationID(purchaseContext, ticketReservation); + notificationManager.sendSimpleEmail(purchaseContext, null, organization.getEmail(), cc, "Reservation complete " + shortReservationID, + () -> templateManager.renderTemplate(purchaseContext, TemplateResource.CONFIRMATION_EMAIL_FOR_ORGANIZER, reservationEmailModel, language), + attachments); + } + + private static boolean mustGenerateBillingDocument(OrderSummary summary, TicketReservation ticketReservation) { + return !summary.getFree() && (!summary.getNotYetPaid() || (summary.getWaitingForPayment() && ticketReservation.isInvoiceRequested())); + } + + public List generateBillingDocumentAttachment(PurchaseContext purchaseContext, + TicketReservation ticketReservation, + Locale language, + Map billingDocumentModel, + BillingDocument.Type documentType) { + Map model = new HashMap<>(); + model.put(RESERVATION_ID, ticketReservation.getId()); + purchaseContext.event().ifPresent(event -> model.put("eventId", Integer.toString(event.getId()))); + model.put("language", Json.toJson(language)); + model.put("reservationEmailModel", Json.toJson(billingDocumentModel));//ticketReservation.getHasInvoiceNumber() + switch (documentType) { + case INVOICE: + return Collections.singletonList(new Mailer.Attachment("invoice.pdf", null, "application/pdf", model, Mailer.AttachmentIdentifier.INVOICE_PDF)); + case RECEIPT: + return Collections.singletonList(new Mailer.Attachment("receipt.pdf", null, "application/pdf", model, Mailer.AttachmentIdentifier.RECEIPT_PDF)); + case CREDIT_NOTE: + return Collections.singletonList(new Mailer.Attachment("credit-note.pdf", null, "application/pdf", model, Mailer.AttachmentIdentifier.CREDIT_NOTE_PDF)); + default: + throw new IllegalStateException(documentType+" is not supported"); + } + } + + public String getReservationEmailSubject(PurchaseContext purchaseContext, Locale reservationLanguage, String key, String id) { + return messageSourceManager.getMessageSourceFor(purchaseContext) + .getMessage(key, new Object[]{id, purchaseContext.getDisplayName()}, reservationLanguage); + } + + public Map prepareModelForReservationEmail(PurchaseContext purchaseContext, + TicketReservation reservation, + Optional vat, + OrderSummary summary, + List ticketsToInclude, + Map initialOptions) { + Organization organization = organizationRepository.getById(purchaseContext.getOrganizationId()); + String baseUrl = configurationManager.baseUrl(purchaseContext); + var reservationId = reservation.getId(); + String reservationUrl = reservationUrl(reservation, purchaseContext, configurationManager); + String reservationShortID = configurationManager.getShortReservationID(purchaseContext, reservation); + + var bankingInfo = configurationManager.getFor(Set.of(INVOICE_ADDRESS, BANK_ACCOUNT_NR, BANK_ACCOUNT_OWNER), ConfigurationLevel.purchaseContext(purchaseContext)); + Optional invoiceAddress = bankingInfo.get(INVOICE_ADDRESS).getValue(); + Optional bankAccountNr = bankingInfo.get(BANK_ACCOUNT_NR).getValue(); + Optional bankAccountOwner = bankingInfo.get(BANK_ACCOUNT_OWNER).getValue(); + + Map> ticketsByCategory = ticketsToInclude + .stream() + .collect(groupingBy(Ticket::getCategoryId)); + final List ticketsWithCategory = ReservationUtil.collectTicketsWithCategory(ticketsByCategory, ticketCategoryRepository); + Map baseModel = new HashMap<>(); + baseModel.putAll(initialOptions); + baseModel.putAll(extensionManager.handleReservationEmailCustomText(purchaseContext, reservation, ticketReservationRepository.getAdditionalInfo(reservationId)) + .map(CustomEmailText::toMap) + .orElse(Map.of())); + Map model = TemplateResource.prepareModelForConfirmationEmail(organization, purchaseContext, reservation, vat, ticketsWithCategory, summary, baseUrl, reservationUrl, reservationShortID, invoiceAddress, bankAccountNr, bankAccountOwner, baseModel); + boolean euBusiness = StringUtils.isNotBlank(reservation.getVatCountryCode()) && StringUtils.isNotBlank(reservation.getVatNr()) + && configurationManager.getForSystem(ConfigurationKeys.EU_COUNTRIES_LIST).getRequiredValue().contains(reservation.getVatCountryCode()) + && PriceContainer.VatStatus.isVatExempt(reservation.getVatStatus()); + model.put("euBusiness", euBusiness); + model.put("publicId", configurationManager.getPublicReservationID(purchaseContext, reservation)); + model.put("invoicingAdditionalInfo", ticketReservationRepository.getAdditionalInfo(reservationId).getInvoicingAdditionalInfo()); + if(purchaseContext.getType() == PurchaseContext.PurchaseContextType.event) { + var event = purchaseContext.event().orElseThrow(); + model.put("displayLocation", ticketsWithCategory.stream() + .noneMatch(tc -> EventUtil.isAccessOnline(tc.getCategory(), event))); + } else { + model.put("displayLocation", false); + } + if(ticketReservationRepository.hasSubscriptionApplied(reservationId)) { + model.put("displaySubscriptionUsage", true); + var subscription = subscriptionRepository.findAppliedSubscriptionByReservationId(reservationId).orElseThrow(); + if(subscription.getMaxEntries() > -1) { + var subscriptionUsageDetails = UsageDetails.fromSubscription(subscription, ticketRepository.countSubscriptionUsage(subscription.getId(), null)); + model.put("subscriptionUsageDetails", subscriptionUsageDetails); + model.put("subscriptionUrl", reservationUrl(reservation, purchaseContext, configurationManager)); + } + } + return model; + } + + public void sendTicketByEmail(Ticket ticket, Locale locale, Event event, PartialTicketTextGenerator confirmationTextBuilder) { + TicketReservation reservation = ticketReservationRepository.findReservationById(ticket.getTicketsReservationId()); + checkIfFinalized(reservation.getId()); + TicketCategory ticketCategory = ticketCategoryRepository.getByIdAndActive(ticket.getCategoryId(), event.getId()); + notificationManager.sendTicketByEmail(ticket, event, locale, confirmationTextBuilder, reservation, ticketCategory, () -> retrieveAttendeeAdditionalInfoForTicket(ticket)); + } + + private void checkIfFinalized(String reservationId) { + if (!Boolean.TRUE.equals(ticketReservationRepository.checkIfFinalized(reservationId))) { + throw new IncompatibleStateException("Reservation was confirmed but not finalized yet. Cannot send emails."); + } + } + + public Map> retrieveAttendeeAdditionalInfoForTicket(Ticket ticket) { + return ticketFieldRepository.findNameAndValue(ticket.getId()) + .stream() + .collect(groupingBy(FieldNameAndValue::getName, mapping(FieldNameAndValue::getValue, toList()))); + } + + public PartialTicketTextGenerator getTicketEmailGenerator(Event event, + TicketReservation ticketReservation, + Locale ticketLanguage, + Map> additionalInfo) { + return ticket -> { + Organization organization = organizationRepository.getById(event.getOrganizationId()); + String ticketUrl = ReservationUtil.ticketUpdateUrl(event, ticket, configurationManager); + var ticketCategory = ticketCategoryRepository.getById(ticket.getCategoryId()); + + var initialModel = new HashMap<>(extensionManager.handleTicketEmailCustomText(event, ticketReservation, ticketReservationRepository.getAdditionalInfo(ticketReservation.getId()), ticketFieldRepository.findAllByTicketId(ticket.getId())) + .map(CustomEmailText::toMap) + .orElse(Map.of())); + if(EventUtil.isAccessOnline(ticketCategory, event)) { + initialModel.putAll(TicketCheckInUtil.getOnlineCheckInInfo( + extensionManager, + eventRepository, + ticketCategoryRepository, + configurationManager, + event, + ticketLanguage, + ticket, + ticketCategory, + additionalInfo + )); + } + var baseUrl = StringUtils.removeEnd(configurationManager.getFor(BASE_URL, ConfigurationLevel.event(event)).getRequiredValue(), "/"); + var calendarUrl = UriComponentsBuilder.fromUriString(baseUrl + "/api/v2/public/event/{eventShortName}/calendar/{currentLang}") + .queryParam("type", "google") + .build(Map.of("eventShortName", event.getShortName(), "currentLang", ticketLanguage.getLanguage())) + .toString(); + return TemplateProcessor.buildPartialEmail(event, organization, ticketReservation, ticketCategory, templateManager, baseUrl, ticketUrl, calendarUrl, ticketLanguage, initialModel).generate(ticket); + }; + } + + public Optional getVAT(PurchaseContext purchaseContext) { + return configurationManager.getFor(VAT_NR, purchaseContext.getConfigurationLevel()).getValue(); + } + + public Map prepareModelForReservationEmail(PurchaseContext purchaseContext, TicketReservation reservation) { + Optional vat = getVAT(purchaseContext); + OrderSummary summary = orderSummaryGenerator.orderSummaryForReservationId(reservation.getId(), purchaseContext); + return prepareModelForReservationEmail(purchaseContext, reservation, vat, summary, ticketRepository.findTicketsInReservation(reservation.getId()), Map.of()); + } +} diff --git a/src/main/java/alfio/manager/support/reservation/TooManyTicketsForDiscountCodeException.java b/src/main/java/alfio/manager/support/reservation/TooManyTicketsForDiscountCodeException.java new file mode 100644 index 0000000000..8c84a7f297 --- /dev/null +++ b/src/main/java/alfio/manager/support/reservation/TooManyTicketsForDiscountCodeException.java @@ -0,0 +1,20 @@ +/** + * 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.manager.support.reservation; + +public class TooManyTicketsForDiscountCodeException extends RuntimeException { +} diff --git a/src/main/java/alfio/manager/system/AdminJobExecutor.java b/src/main/java/alfio/manager/system/AdminJobExecutor.java index f4c1364690..250fee3691 100644 --- a/src/main/java/alfio/manager/system/AdminJobExecutor.java +++ b/src/main/java/alfio/manager/system/AdminJobExecutor.java @@ -24,14 +24,25 @@ public interface AdminJobExecutor { enum JobName { - CHECK_OFFLINE_PAYMENTS, - SEND_TICKET_ASSIGNMENT_REMINDER, - SEND_OFFLINE_PAYMENT_REMINDER, - UNKNOWN, - SEND_OFFLINE_PAYMENT_TO_ORGANIZER, - REGENERATE_INVOICES, - ASSIGN_TICKETS_TO_SUBSCRIBERS, - EXECUTE_EXTENSION; + CHECK_OFFLINE_PAYMENTS(false), + SEND_TICKET_ASSIGNMENT_REMINDER(false), + SEND_OFFLINE_PAYMENT_REMINDER(false), + UNKNOWN(false), + SEND_OFFLINE_PAYMENT_TO_ORGANIZER(false), + REGENERATE_INVOICES(false), + ASSIGN_TICKETS_TO_SUBSCRIBERS(false), + EXECUTE_EXTENSION(true), + RETRY_RESERVATION_CONFIRMATION(true); + + private final boolean allowsMultiple; + + JobName(boolean allowsMultiple) { + this.allowsMultiple = allowsMultiple; + } + + public boolean allowsMultipleScheduling() { + return allowsMultiple; + } public static JobName safeValueOf(String value) { return Arrays.stream(values()) diff --git a/src/main/java/alfio/manager/system/AdminJobManager.java b/src/main/java/alfio/manager/system/AdminJobManager.java index 94527157a0..f462727099 100644 --- a/src/main/java/alfio/manager/system/AdminJobManager.java +++ b/src/main/java/alfio/manager/system/AdminJobManager.java @@ -25,7 +25,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; @@ -46,11 +45,12 @@ public class AdminJobManager { static final int MAX_ATTEMPTS = 17; // will retry for approximately 36h - private static final Set ADMIN_JOBS = EnumSet.complementOf(EnumSet.of(JobName.EXECUTE_EXTENSION)) - .stream() + private static final Set REGULAR = EnumSet.complementOf(EnumSet.of(JobName.EXECUTE_EXTENSION, JobName.RETRY_RESERVATION_CONFIRMATION)); + private static final Set ADMIN_JOBS = REGULAR.stream() .map(Enum::name) .collect(toSet()); private static final Set EXTENSIONS_JOB = Set.of(JobName.EXECUTE_EXTENSION.name()); + private static final Set RESERVATIONS_JOB = Set.of(JobName.RETRY_RESERVATION_CONFIRMATION.name()); private final Map> executorsByJobId; private final AdminJobQueueRepository adminJobQueueRepository; private final TransactionTemplate nestedTransactionTemplate; @@ -74,19 +74,15 @@ public AdminJobManager(List jobExecutors, this.clockProvider = clockProvider; } - @Scheduled(fixedDelay = 1000L) - void processPendingExtensionRetry() { - log.trace("Processing pending extensions retry"); - processPendingExtensionRetry(ZonedDateTime.now(clockProvider.getClock())); - log.trace("done processing pending extensions retry"); - } - // internal method invoked by tests void processPendingExtensionRetry(ZonedDateTime timestamp) { internalProcessPendingSchedules(adminJobQueueRepository.loadPendingSchedules(EXTENSIONS_JOB, timestamp)); } - @Scheduled(fixedDelay = 60 * 1000) + void processPendingReservationsRetry(ZonedDateTime timestamp) { + internalProcessPendingSchedules(adminJobQueueRepository.loadPendingSchedules(RESERVATIONS_JOB, timestamp)); + } + void processPendingRequests() { log.trace("Processing pending requests"); internalProcessPendingSchedules(adminJobQueueRepository.loadPendingSchedules(ADMIN_JOBS, ZonedDateTime.now(clockProvider.getClock()))); @@ -102,12 +98,11 @@ private void internalProcessPendingSchedules(List pendingSched var partitionedResults = scheduleWithResults.getRight().stream().collect(Collectors.partitioningBy(Result::isSuccess)); if(!partitionedResults.get(false).isEmpty()) { partitionedResults.get(false).forEach(r -> log.warn("Processing failed for {}: {}", schedule.getJobName(), r.getErrors())); - if (schedule.getJobName() != JobName.EXECUTE_EXTENSION || schedule.getAttempts() > MAX_ATTEMPTS) { + if (REGULAR.contains(schedule.getJobName()) || schedule.getAttempts() > MAX_ATTEMPTS) { adminJobQueueRepository.updateSchedule(schedule.getId(), AdminJobSchedule.Status.FAILED, ZonedDateTime.now(clockProvider.getClock()), Map.of()); } else { var nextExecution = getNextExecution(schedule.getAttempts()); - var extensionName = schedule.getMetadata().get("extensionName"); - log.debug("scheduling failed extension {} to be executed at {}", extensionName, nextExecution); + logReschedule(nextExecution, schedule.getMetadata(), schedule.getJobName()); adminJobQueueRepository.scheduleRetry(schedule.getId(), nextExecution); } } else { @@ -126,7 +121,6 @@ static ZonedDateTime getNextExecution(int currentAttempt) { .plusSeconds((long) Math.pow(2, currentAttempt + 1D)); } - @Scheduled(cron = "#{environment.acceptsProfiles('dev') ? '0 * * * * *' : '0 0 0 * * *'}") void cleanupExpiredRequests() { log.trace("Cleanup expired requests"); ZonedDateTime now = ZonedDateTime.now(clockProvider.getClock()); @@ -164,7 +158,10 @@ private Pair>> processPendingRequest(Admin public static Function executionScheduler(JobName jobName, Map metadata, ZonedDateTime executionTime) { return adminJobQueueRepository -> { try { - int result = adminJobQueueRepository.schedule(jobName, executionTime, metadata); + int result = adminJobQueueRepository.schedule(jobName, executionTime, metadata, + // by setting a null value, we actually disable the unique constraint for this job name + // and allow multiple rows to be present for the same timestamp + jobName.allowsMultipleScheduling() ? null : "N"); if (result == 0) { log.trace("Possible duplication detected while inserting {}", jobName); } @@ -175,4 +172,15 @@ public static Function executionScheduler(JobN } }; } + + private static void logReschedule(ZonedDateTime nextExecution, Map metadata, JobName jobName) { + String name; + boolean isExtension = jobName == JobName.EXECUTE_EXTENSION; + if (isExtension) { + name = String.valueOf(metadata.get("extensionName")); + } else { + name = String.valueOf(metadata.get("reservationId")); + } + log.debug("scheduling failed {} {} to be executed at {}", isExtension ? "extension" : "reservation", name, nextExecution); + } } diff --git a/src/main/java/alfio/manager/system/AdminJobManagerScheduler.java b/src/main/java/alfio/manager/system/AdminJobManagerScheduler.java new file mode 100644 index 0000000000..0612bc7c5a --- /dev/null +++ b/src/main/java/alfio/manager/system/AdminJobManagerScheduler.java @@ -0,0 +1,68 @@ +/** + * 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.manager.system; + +import alfio.config.Initializer; +import alfio.util.ClockProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; + +@Component +@Profile("!" + Initializer.PROFILE_DISABLE_JOBS) +public class AdminJobManagerScheduler { + + private static final Logger log = LoggerFactory.getLogger(AdminJobManagerScheduler.class); + private final AdminJobManager adminJobManager; + private final ClockProvider clockProvider; + + public AdminJobManagerScheduler(AdminJobManager adminJobManager, + ClockProvider clockProvider) { + this.adminJobManager = adminJobManager; + this.clockProvider = clockProvider; + } + + @Scheduled(fixedDelay = 1000L) + void processPendingExtensionRetry() { + log.trace("Processing pending extensions retry"); + adminJobManager.processPendingExtensionRetry(ZonedDateTime.now(clockProvider.getClock())); + log.trace("done processing pending extensions retry"); + } + + @Scheduled(fixedDelay = 1000L) + void processPendingReservationsRetry() { + log.trace("Processing pending reservations retry"); + adminJobManager.processPendingReservationsRetry(ZonedDateTime.now(clockProvider.getClock())); + log.trace("done processing pending reservations retry"); + } + + @Scheduled(fixedDelay = 60 * 1000) + void processPendingRequests() { + log.trace("Processing pending requests"); + adminJobManager.processPendingRequests(); + log.trace("done processing pending requests"); + } + + @Scheduled(cron = "#{environment.acceptsProfiles('dev') ? '0 * * * * *' : '0 0 0 * * *'}") + void cleanupExpiredRequests() { + adminJobManager.cleanupExpiredRequests(); + } +} diff --git a/src/main/java/alfio/model/ReservationMetadata.java b/src/main/java/alfio/model/ReservationMetadata.java index d896ad4bf0..b87d085087 100644 --- a/src/main/java/alfio/model/ReservationMetadata.java +++ b/src/main/java/alfio/model/ReservationMetadata.java @@ -20,14 +20,37 @@ import com.fasterxml.jackson.annotation.JsonProperty; public class ReservationMetadata { + private final boolean hideContactData; + private final boolean readyForConfirmation; + private final boolean finalized; @JsonCreator - public ReservationMetadata(@JsonProperty("hideContactData") Boolean hideContactData) { + public ReservationMetadata(@JsonProperty("hideContactData") Boolean hideContactData, + @JsonProperty("readyForConfirmation") Boolean readyForConfirmation, + @JsonProperty("finalized") Boolean finalized) { this.hideContactData = Boolean.TRUE.equals(hideContactData); + this.readyForConfirmation = Boolean.TRUE.equals(readyForConfirmation); + this.finalized = Boolean.TRUE.equals(finalized); } public boolean isHideContactData() { return hideContactData; } + + public boolean isFinalized() { + return finalized; + } + + public boolean isReadyForConfirmation() { + return readyForConfirmation; + } + + public ReservationMetadata withFinalized(boolean newValue) { + return new ReservationMetadata(hideContactData, readyForConfirmation, newValue); + } + + public ReservationMetadata withReadyForConfirmation(boolean newValue) { + return new ReservationMetadata(hideContactData, newValue, finalized); + } } diff --git a/src/main/java/alfio/model/TicketReservation.java b/src/main/java/alfio/model/TicketReservation.java index ff98ba0e22..64640139c9 100644 --- a/src/main/java/alfio/model/TicketReservation.java +++ b/src/main/java/alfio/model/TicketReservation.java @@ -39,6 +39,14 @@ public enum TicketReservationStatus { WAITING_EXTERNAL_CONFIRMATION, OFFLINE_PAYMENT, DEFERRED_OFFLINE_PAYMENT, + /** + * Reservation is in the process of being finalized + */ + FINALIZING, + /** + * Special finalization status for OFFLINE payment + */ + OFFLINE_FINALIZING, COMPLETE, STUCK, CANCELLED, diff --git a/src/main/java/alfio/model/system/command/FinalizeReservation.java b/src/main/java/alfio/model/system/command/FinalizeReservation.java new file mode 100644 index 0000000000..4fcde06830 --- /dev/null +++ b/src/main/java/alfio/model/system/command/FinalizeReservation.java @@ -0,0 +1,91 @@ +/** + * 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.model.system.command; + +import alfio.manager.payment.PaymentSpecification; +import alfio.model.TicketReservation.TicketReservationStatus; +import alfio.model.transaction.PaymentProxy; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +public class FinalizeReservation { + private final PaymentSpecification paymentSpecification; + private final PaymentProxy paymentProxy; + private final boolean sendReservationConfirmationEmail; + private final boolean sendTickets; + private final String username; + private final TicketReservationStatus originalStatus; + + @JsonCreator + public FinalizeReservation(@JsonProperty("paymentSpecification") PaymentSpecification paymentSpecification, + @JsonProperty("paymentProxy") PaymentProxy paymentProxy, + @JsonProperty("sendReservationConfirmationEmail") boolean sendReservationConfirmationEmail, + @JsonProperty("sendTickets") boolean sendTickets, + @JsonProperty("username") String username, + @JsonProperty("originalStatus") TicketReservationStatus originalStatus) { + this.paymentSpecification = paymentSpecification; + this.paymentProxy = paymentProxy; + this.sendReservationConfirmationEmail = sendReservationConfirmationEmail; + this.sendTickets = sendTickets; + this.username = username; + this.originalStatus = originalStatus; + } + + public PaymentSpecification getPaymentSpecification() { + return paymentSpecification; + } + + public PaymentProxy getPaymentProxy() { + return paymentProxy; + } + + public boolean isSendReservationConfirmationEmail() { + return sendReservationConfirmationEmail; + } + + public boolean isSendTickets() { + return sendTickets; + } + + public String getUsername() { + return username; + } + + public TicketReservationStatus getOriginalStatus() { + return originalStatus; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof FinalizeReservation)) { + return false; + } + FinalizeReservation that = (FinalizeReservation) o; + return sendReservationConfirmationEmail == that.sendReservationConfirmationEmail + && sendTickets == that.sendTickets + && paymentSpecification.equals(that.paymentSpecification) + && paymentProxy == that.paymentProxy + && Objects.equals(username, that.username); + } + + @Override + public int hashCode() { + return Objects.hash(paymentSpecification, paymentProxy, sendReservationConfirmationEmail, sendTickets, username); + } +} diff --git a/src/main/java/alfio/repository/OrganizationDeleterRepository.java b/src/main/java/alfio/repository/OrganizationDeleterRepository.java index 50460322ac..c6995a4dea 100644 --- a/src/main/java/alfio/repository/OrganizationDeleterRepository.java +++ b/src/main/java/alfio/repository/OrganizationDeleterRepository.java @@ -67,6 +67,11 @@ public interface OrganizationDeleterRepository { " and id not in (" + SELECT_EMPTY_ORGANIZATIONS + ")") int deleteOrganizationsIfEmpty(@Bind("organizationIds") List organizationIds); + @Query("delete from tickets_reservation where organization_id_fk in (:organizationIds)") + int deleteReservations(@Bind("organizationIds") List organizationIds); + + @Query("delete from admin_reservation_request where organization_id_fk in (:organizationIds)") + int deleteAdminReservationRequests(@Bind("organizationIds") List organizationIds); default void deleteEmptyOrganizations(List organizationIds) { // delete invoice sequences @@ -93,6 +98,14 @@ default void deleteEmptyOrganizations(List organizationIds) { int deletedDescriptors = deleteSubscriptionDescriptors(organizationIds); LOGGER.info("deleted {} subscription descriptors and {} subscriptions", deletedDescriptors, deletedSubscriptions); + // delete all reservations + int deletedReservations = deleteReservations(organizationIds); + LOGGER.info("deleted {} reservations", deletedReservations); + + // delete admin reservation request + int deletedAdminReservationRequests = deleteAdminReservationRequests(organizationIds); + LOGGER.info("deleted {} adminReservationRequests", deletedAdminReservationRequests); + // delete promo codes int deletedPromoCodes = deletePromoCodes(organizationIds); LOGGER.info("deleted {} promo codes", deletedPromoCodes); diff --git a/src/main/java/alfio/repository/TicketReservationRepository.java b/src/main/java/alfio/repository/TicketReservationRepository.java index d22502f657..301131c17f 100644 --- a/src/main/java/alfio/repository/TicketReservationRepository.java +++ b/src/main/java/alfio/repository/TicketReservationRepository.java @@ -237,6 +237,9 @@ int updateTicketReservationWithValidation(@Bind("reservationId") String reservat @JSONData ReservationMetadata getMetadata(@Bind("id") String reservationId); + @Query("select metadata->'finalized' = 'true' as finalized from tickets_reservation where id = :id") + Boolean checkIfFinalized(@Bind("id") String reservationId); + @Query("update tickets_reservation set metadata = :metadata::jsonb where id = :id") int setMetadata(@Bind("id") String reservationId, @Bind("metadata") @JSONData ReservationMetadata metadata); diff --git a/src/main/java/alfio/repository/system/AdminJobQueueRepository.java b/src/main/java/alfio/repository/system/AdminJobQueueRepository.java index 919eeba362..38d285ce11 100644 --- a/src/main/java/alfio/repository/system/AdminJobQueueRepository.java +++ b/src/main/java/alfio/repository/system/AdminJobQueueRepository.java @@ -17,7 +17,6 @@ package alfio.repository.system; import alfio.manager.system.AdminJobExecutor.JobName; -import alfio.model.support.EnumTypeAsString; import alfio.model.support.JSONData; import alfio.model.system.AdminJobSchedule; import ch.digitalfondue.npjt.Bind; @@ -49,12 +48,13 @@ int updateSchedule(@Bind("id") long id, int scheduleRetry(@Bind("id") long id, @Bind("requestTs") ZonedDateTime requestTs); - @Query("insert into admin_job_queue(job_name, request_ts, metadata, status, attempts)" + - " values(:jobName, :requestTs, to_json(:metadata::json), 'SCHEDULED', 1)" + + @Query("insert into admin_job_queue(job_name, request_ts, metadata, status, attempts, allow_duplicates)" + + " values(:jobName, :requestTs, to_json(:metadata::json), 'SCHEDULED', 1, :allowDuplicates)" + " on conflict do nothing") int schedule(@Bind("jobName") JobName jobName, @Bind("requestTs") ZonedDateTime requestTimestamp, - @Bind("metadata") @JSONData Map metadata); + @Bind("metadata") @JSONData Map metadata, + @Bind("allowDuplicates") String allowDuplicates); @Query("delete from admin_job_queue where status in (:status) and request_ts <= :requestTs") int removePastSchedules(@Bind("requestTs") ZonedDateTime requestTs, @Bind("status") Set statuses); diff --git a/src/main/java/alfio/util/ReservationUtil.java b/src/main/java/alfio/util/ReservationUtil.java index 907806d843..b93b5e3c23 100644 --- a/src/main/java/alfio/util/ReservationUtil.java +++ b/src/main/java/alfio/util/ReservationUtil.java @@ -22,8 +22,12 @@ import alfio.manager.PromoCodeRequestManager; import alfio.manager.TicketReservationManager; import alfio.manager.support.response.ValidatedResponse; +import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.modification.*; +import alfio.repository.TicketCategoryRepository; +import alfio.repository.TicketRepository; +import alfio.repository.TicketReservationRepository; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.springframework.validation.BindingResult; @@ -31,9 +35,7 @@ import java.math.BigDecimal; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import static java.util.Collections.emptyList; @@ -160,4 +162,49 @@ private static List selectedAdditional .filter(e -> e != null && e.getQuantity() != null && e.getAdditionalServiceId() != null && e.getQuantity() > 0) .collect(toList()); } + + public static boolean hasPrivacyPolicy(PurchaseContext event) { + return StringUtils.isNotBlank(event.getPrivacyPolicyLinkOrNull()); + } + + public static String ticketUpdateUrl(Event event, Ticket ticket, ConfigurationManager configurationManager) { + return configurationManager.baseUrl(event) + "/event/" + event.getShortName() + "/ticket/" + ticket.getUuid() + "/update?lang=" + ticket.getUserLanguage(); + } + + public static String reservationUrl(String baseUrl, String reservationId, + PurchaseContext purchaseContext, + String userLanguage, + String additionalParams) { + var cleanParams = StringUtils.trimToNull(additionalParams); + return StringUtils.removeEnd(baseUrl, "/") + + "/" + purchaseContext.getType() + + "/" + purchaseContext.getPublicIdentifier() + + "/reservation/" + reservationId + + "?lang="+userLanguage + + (cleanParams != null ? "&" + cleanParams : ""); + } + public static String reservationUrl(String baseUrl, String reservationId, PurchaseContext purchaseContext, String userLanguage) { + return reservationUrl(baseUrl, reservationId, purchaseContext, userLanguage, null); + } + + public static String reservationUrl(TicketReservation reservation, PurchaseContext purchaseContext, ConfigurationManager configurationManager) { + return reservationUrl(configurationManager.baseUrl(purchaseContext), reservation.getId(), purchaseContext, reservation.getUserLanguage()); + } + + public static List collectTicketsWithCategory(Map> ticketsByCategory, TicketCategoryRepository ticketCategoryRepository) { + final List ticketsWithCategory; + if(!ticketsByCategory.isEmpty()) { + ticketsWithCategory = ticketCategoryRepository.findByIds(ticketsByCategory.keySet()) + .stream() + .flatMap(tc -> ticketsByCategory.get(tc.getId()).stream().map(t -> new TicketWithCategory(t, tc))) + .collect(toList()); + } else { + ticketsWithCategory = Collections.emptyList(); + } + return ticketsWithCategory; + } + + public static Locale getReservationLocale(TicketReservation reservation) { + return StringUtils.isEmpty(reservation.getUserLanguage()) ? Locale.ENGLISH : LocaleUtil.forLanguageTag(reservation.getUserLanguage()); + } } diff --git a/src/main/resources/alfio/db/PGSQL/V204_2.0.0.49.4__ADMIN_JOB_ALLOW_DUPLICATES.sql b/src/main/resources/alfio/db/PGSQL/V204_2.0.0.49.4__ADMIN_JOB_ALLOW_DUPLICATES.sql new file mode 100644 index 0000000000..e68be1789d --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V204_2.0.0.49.4__ADMIN_JOB_ALLOW_DUPLICATES.sql @@ -0,0 +1,21 @@ +-- +-- 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 . +-- + +alter table admin_job_queue add column allow_duplicates char(1) default 'N'; +alter table admin_job_queue drop constraint "unique_job_schedule"; +alter table admin_job_queue add constraint "unique_job_schedule" unique(job_name, request_ts, allow_duplicates); + diff --git a/src/main/resources/alfio/i18n/public.properties b/src/main/resources/alfio/i18n/public.properties index 947dad7fa3..81df92f050 100644 --- a/src/main/resources/alfio/i18n/public.properties +++ b/src/main/resources/alfio/i18n/public.properties @@ -147,6 +147,7 @@ reservation-page-complete.ticket-type=Ticket type\: reservation-page-complete.ticket-not-assigned=not yet assigned reservation-page-complete.ticket-nr=Ticket # reservation-page-complete.order-information=Order information: {0} by {1} +reservation-page-complete.reservation.finalization-in-progress=Reservation is being processed. You''ll receive an email as soon as processing is complete. reservation-page-complete.job-title=Job title reservation-page-complete.company=Company diff --git a/src/test/java/alfio/BaseTestConfiguration.java b/src/test/java/alfio/BaseTestConfiguration.java index 9307d1d99e..ff88974064 100644 --- a/src/test/java/alfio/BaseTestConfiguration.java +++ b/src/test/java/alfio/BaseTestConfiguration.java @@ -21,28 +21,22 @@ import alfio.manager.FileDownloadManager; import alfio.manager.system.ExternalConfiguration; import alfio.model.system.ConfigurationKeys; -import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; +import alfio.util.RefreshableDataSource; import com.stripe.Stripe; import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -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; import java.net.http.HttpClient; @@ -60,6 +54,7 @@ @Configuration(proxyBeanMethods = false) public class BaseTestConfiguration { + public static final int MAX_POOL_SIZE = 5; private static final Logger log = LoggerFactory.getLogger(BaseTestConfiguration.class); @Bean @@ -70,7 +65,7 @@ public PlatformProvider getCloudProvider() { @Bean @Profile("!travis") - public DataSource getDataSource() { + public RefreshableDataSource dataSource() { String POSTGRES_DB = "alfio"; String postgresVersion = Objects.requireNonNullElse(System.getProperty("pgsql.version"), "9.6"); log.debug("Running tests using PostgreSQL v.{}", postgresVersion); @@ -83,8 +78,8 @@ public DataSource getDataSource() { config.setUsername("alfio_user"); config.setPassword("password"); config.setDriverClassName(postgres.getDriverClassName()); - config.setMaximumPoolSize(5); - return new HikariDataSource(config); + config.setMaximumPoolSize(MAX_POOL_SIZE); + return new RefreshableDataSource(config); } @Bean diff --git a/src/test/java/alfio/controller/api/admin/EventApiControllerIntegrationTest.java b/src/test/java/alfio/controller/api/admin/EventApiControllerIntegrationTest.java index 00dad20e33..8c250ae5f2 100644 --- a/src/test/java/alfio/controller/api/admin/EventApiControllerIntegrationTest.java +++ b/src/test/java/alfio/controller/api/admin/EventApiControllerIntegrationTest.java @@ -31,6 +31,7 @@ import alfio.repository.EventRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.AfterEach; @@ -55,10 +56,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.when; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class EventApiControllerIntegrationTest { @Autowired diff --git a/src/test/java/alfio/controller/api/admin/PollAdminApiControllerTest.java b/src/test/java/alfio/controller/api/admin/PollAdminApiControllerTest.java index 9b9bffd467..3952e2ce76 100644 --- a/src/test/java/alfio/controller/api/admin/PollAdminApiControllerTest.java +++ b/src/test/java/alfio/controller/api/admin/PollAdminApiControllerTest.java @@ -32,6 +32,7 @@ import alfio.repository.*; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.time.DateUtils; @@ -55,10 +56,9 @@ import static alfio.test.util.TestUtil.clockProvider; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class PollAdminApiControllerTest { @Autowired diff --git a/src/test/java/alfio/controller/api/v1/EventApiV1IntegrationTest.java b/src/test/java/alfio/controller/api/v1/EventApiV1IntegrationTest.java index 86ee42112c..20714b8af0 100644 --- a/src/test/java/alfio/controller/api/v1/EventApiV1IntegrationTest.java +++ b/src/test/java/alfio/controller/api/v1/EventApiV1IntegrationTest.java @@ -35,6 +35,7 @@ import alfio.repository.TicketCategoryRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -58,10 +59,9 @@ import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class EventApiV1IntegrationTest extends BaseIntegrationTest { @BeforeAll diff --git a/src/test/java/alfio/controller/api/v1/ReservationApiV1ControllerTest.java b/src/test/java/alfio/controller/api/v1/ReservationApiV1ControllerTest.java index 9ff66588de..14531737f2 100644 --- a/src/test/java/alfio/controller/api/v1/ReservationApiV1ControllerTest.java +++ b/src/test/java/alfio/controller/api/v1/ReservationApiV1ControllerTest.java @@ -40,6 +40,7 @@ import alfio.repository.*; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.ClockProvider; import org.apache.commons.lang3.StringUtils; @@ -62,10 +63,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class ReservationApiV1ControllerTest { private static final String DEFAULT_CATEGORY_NAME = "default"; diff --git a/src/test/java/alfio/controller/api/v1/SubscriptionApiV1IntegrationTest.java b/src/test/java/alfio/controller/api/v1/SubscriptionApiV1IntegrationTest.java index 5348e6eafb..b2b1454b26 100644 --- a/src/test/java/alfio/controller/api/v1/SubscriptionApiV1IntegrationTest.java +++ b/src/test/java/alfio/controller/api/v1/SubscriptionApiV1IntegrationTest.java @@ -40,6 +40,7 @@ import alfio.repository.SubscriptionRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.ClockProvider; import org.junit.jupiter.api.Assertions; @@ -69,10 +70,9 @@ import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class SubscriptionApiV1IntegrationTest { @Autowired diff --git a/src/test/java/alfio/controller/api/v2/user/PollApiControllerIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/PollApiControllerIntegrationTest.java index 55030dfa19..b8d75ef05c 100644 --- a/src/test/java/alfio/controller/api/v2/user/PollApiControllerIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/PollApiControllerIntegrationTest.java @@ -36,6 +36,7 @@ import alfio.repository.*; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -48,11 +49,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; -import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; @@ -62,10 +61,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class PollApiControllerIntegrationTest { private static final Logger LOGGER = LoggerFactory.getLogger(PollApiControllerIntegrationTest.class); 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 887f405bba..2e8866388d 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 @@ -95,9 +95,11 @@ import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; +import java.util.stream.Stream; import static alfio.config.authentication.support.AuthenticationConstants.SYSTEM_API_CLIENT; import static alfio.manager.support.extension.ExtensionEvent.*; +import static alfio.model.system.ConfigurationKeys.TRANSLATION_OVERRIDE; import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; @@ -207,9 +209,13 @@ private void ensureConfiguration(ReservationFlowContext context) { specialPriceTokenGenerator.generatePendingCodes(); } + protected Stream getExtensionEventsToRegister() { + return allEvents(); + } + protected void testBasicFlow(Supplier contextSupplier) throws Exception { // as soon as the test starts, insert the extension in the database (prepare the environment) - insertExtension(extensionService, "/extension.js"); + insertExtension(extensionService, "/extension.js", getExtensionEventsToRegister()); List body = eventApiV2Controller.listEvents(SearchOptions.empty()).getBody(); assertNotNull(body); assertTrue(body.isEmpty()); @@ -312,7 +318,7 @@ protected void testBasicFlow(Supplier contextSupplier) t assertEquals(context.event.getFileBlobId(), selectedEvent.getFileBlobId()); assertTrue(selectedEvent.getI18nOverride().isEmpty()); - configurationRepository.insert("TRANSLATION_OVERRIDE", Json.toJson(Map.of("en", Map.of("show-context.event.tickets.left", "{0} left!"))), ""); + configurationRepository.insert(TRANSLATION_OVERRIDE.name(), Json.toJson(Map.of("en", Map.of("show-context.event.tickets.left", "{0} left!"))), ""); configurationRepository.insertEventLevel(context.event.getOrganizationId(), context.event.getId(),"TRANSLATION_OVERRIDE", Json.toJson(Map.of("en", Map.of("common.vat", "context.event.vat"))), ""); eventRes = eventApiV2Controller.getEvent(context.event.getShortName(), new MockHttpSession()); selectedEvent = eventRes.getBody(); @@ -812,6 +818,8 @@ protected void testBasicFlow(Supplier contextSupplier) t contactForm.setFirstName("full"); contactForm.setLastName("name"); + customizeContactFormForSuccessfulReservation(contactForm); + var ticketForm = new UpdateTicketOwnerForm(); ticketForm.setFirstName("ticketfull"); ticketForm.setLastName("ticketname"); @@ -858,7 +866,7 @@ protected void testBasicFlow(Supplier contextSupplier) t // initialize and confirm payment performAndValidatePayment(context, reservationId, promoCodeId, this::cleanupExtensionLog); - checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.COMPLETE, context); + ensureReservationIsComplete(reservationId, context); reservation = reservationApiV2Controller.getReservationInfo(reservationId, context.getPublicUser()).getBody(); assertNotNull(reservation); @@ -948,8 +956,8 @@ protected void testBasicFlow(Supplier contextSupplier) t //no invoice, but receipt - assertEquals(HttpStatus.NOT_FOUND, reservationApiV2Controller.getInvoice(context.event.getShortName(), reservationId, new MockHttpServletResponse(), context.getPublicAuthentication()).getStatusCode()); - assertEquals(HttpStatus.OK, reservationApiV2Controller.getReceipt(context.event.getShortName(), reservationId, new MockHttpServletResponse(), context.getPublicAuthentication()).getStatusCode()); + assertEquals(contactForm.isInvoiceRequested() ? HttpStatus.OK : HttpStatus.NOT_FOUND, reservationApiV2Controller.getInvoice(context.event.getShortName(), reservationId, new MockHttpServletResponse(), context.getPublicAuthentication()).getStatusCode()); + assertEquals(contactForm.isInvoiceRequested() ? HttpStatus.NOT_FOUND : HttpStatus.OK, reservationApiV2Controller.getReceipt(context.event.getShortName(), reservationId, new MockHttpServletResponse(), context.getPublicAuthentication()).getStatusCode()); @@ -1192,6 +1200,14 @@ protected void testBasicFlow(Supplier contextSupplier) t } + protected void customizeContactFormForSuccessfulReservation(ContactAndTicketsForm contactForm) { + + } + + protected void ensureReservationIsComplete(String reservationId, ReservationFlowContext context) { + checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.COMPLETE, context); + } + private void checkReservationExport(ReservationFlowContext context) { Principal principal = mock(Principal.class); Mockito.when(principal.getName()).thenReturn(context.userId); @@ -1211,14 +1227,18 @@ private void checkReservationExport(ReservationFlowContext context) { assertThrows(IllegalArgumentException.class, () -> exportManager.reservationsForInterval(wrongFrom, now, principal)); } - static void insertExtension(ExtensionService extensionService, String path) throws IOException { - insertExtension(extensionService, path, true, true); + static void insertExtension(ExtensionService extensionService, String path, Stream events) throws IOException { + insertExtension(extensionService, path, true, true, events); + } + + static Stream allEvents() { + return Arrays.stream(ExtensionEvent.values()).map(ee -> "'"+ee.name()+"'"); } - static void insertExtension(ExtensionService extensionService, String path, boolean async, boolean sync) throws IOException { + static void insertExtension(ExtensionService extensionService, String path, boolean async, boolean sync, Stream events) throws IOException { try (var extensionInputStream = requireNonNull(BaseReservationFlowTest.class.getResourceAsStream(path))) { List extensionStream = IOUtils.readLines(new InputStreamReader(extensionInputStream, StandardCharsets.UTF_8)); - String concatenation = String.join("\n", extensionStream).replace("EVENTS", Arrays.stream(ExtensionEvent.values()).map(ee -> "'"+ee.name()+"'").collect(Collectors.joining(","))); + String concatenation = String.join("\n", extensionStream).replace("EVENTS", events.collect(Collectors.joining(","))); if (sync) { extensionService.createOrUpdate(null, null, new Extension("-", "syncName", concatenation.replace("placeHolder", "false"), true)); } @@ -1448,13 +1468,13 @@ private void cleanupExtensionLog() { jdbcTemplate.update("delete from extension_log", Map.of()); } - private void assertEventLogged(List extLog, ExtensionEvent event, int logSize) { + protected void assertEventLogged(List extLog, ExtensionEvent event, int logSize) { assertEquals(logSize, extLog.size()); // each event logs exactly two logs assertTrue(extLog.stream().anyMatch(l -> l.getDescription().equals(event.name()))); } protected void assertEventLogged(List extLog, ExtensionEvent event) { - assertTrue(extLog.stream().anyMatch(l -> l.getDescription().equals(event.name()))); + assertTrue(extLog.stream().anyMatch(l -> l.getDescription().equals(event.name())), event.name() + " not found"); } protected final void checkStatus(String reservationId, @@ -1483,11 +1503,15 @@ protected void validatePayment(String eventName, String reservationIdentifier, R assertEquals(10, reservation.getVatCts()); assertEquals(0, reservation.getDiscountCts()); assertEquals(1, eventApiController.getPendingPayments(eventName).size()); - assertEquals("OK", eventApiController.confirmPayment(eventName, reservationIdentifier, principal)); + confirmPayment(eventName, reservationIdentifier, principal); assertEquals(0, eventApiController.getPendingPayments(eventName).size()); assertEquals(1000, eventRepository.getGrossIncome(context.event.getId())); } + private void confirmPayment(String eventName, String reservationIdentifier, Principal principal) { + assertEquals("OK", eventApiController.confirmPayment(eventName, reservationIdentifier, principal)); + } + private void checkCalendar(String eventName) { MockHttpServletResponse resIcal = new MockHttpServletResponse(); eventApiV2Controller.getCalendar(eventName, "en", null, null, resIcal); diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/BillingDocumentCreationIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/BillingDocumentCreationIntegrationTest.java index 3381f6a224..ccfc5d212f 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/BillingDocumentCreationIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/BillingDocumentCreationIntegrationTest.java @@ -45,6 +45,7 @@ import alfio.repository.TicketRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -77,10 +78,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class BillingDocumentCreationIntegrationTest extends BaseIntegrationTest { @Autowired diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/CustomTaxPolicyIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/CustomTaxPolicyIntegrationTest.java index d347aa7e8a..464aebeed4 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/CustomTaxPolicyIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/CustomTaxPolicyIntegrationTest.java @@ -40,15 +40,14 @@ import alfio.repository.TicketRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; -import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.BeanPropertyBindingResult; import org.testcontainers.shaded.org.apache.commons.lang.time.DateUtils; @@ -58,15 +57,13 @@ import java.time.LocalTime; import java.util.*; -import static alfio.controller.api.v2.user.reservation.BaseReservationFlowTest.URL_CODE_HIDDEN; -import static alfio.controller.api.v2.user.reservation.BaseReservationFlowTest.insertExtension; +import static alfio.controller.api.v2.user.reservation.BaseReservationFlowTest.*; import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class CustomTaxPolicyIntegrationTest { private final OrganizationRepository organizationRepository; @@ -109,7 +106,7 @@ public CustomTaxPolicyIntegrationTest(OrganizationRepository organizationReposit private ReservationFlowContext createContext(PriceContainer.VatStatus vatStatus) { try { IntegrationTestUtil.ensureMinimalConfiguration(configurationRepository); - insertExtension(extensionService, "/custom-tax-policy-extension.js", false, true); + insertExtension(extensionService, "/custom-tax-policy-extension.js", false, true, allEvents()); 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())), diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/DiscountedReservationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/DiscountedReservationFlowIntegrationTest.java index 18b05ec108..24beb96688 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/DiscountedReservationFlowIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/DiscountedReservationFlowIntegrationTest.java @@ -45,6 +45,7 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.Test; @@ -68,10 +69,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class DiscountedReservationFlowIntegrationTest extends BaseReservationFlowTest { private final OrganizationRepository organizationRepository; diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/HybridEventReservationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/HybridEventReservationFlowIntegrationTest.java index 6ade090930..430b44783d 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/HybridEventReservationFlowIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/HybridEventReservationFlowIntegrationTest.java @@ -44,6 +44,7 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; @@ -63,10 +64,9 @@ import static alfio.test.util.IntegrationTestUtil.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class HybridEventReservationFlowIntegrationTest extends BaseReservationFlowTest { private final OrganizationRepository organizationRepository; diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/OnlineEventReservationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/OnlineEventReservationFlowIntegrationTest.java index 1b14147ed5..635a5c5911 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/OnlineEventReservationFlowIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/OnlineEventReservationFlowIntegrationTest.java @@ -44,6 +44,7 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; @@ -63,11 +64,10 @@ import static alfio.test.util.IntegrationTestUtil.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class OnlineEventReservationFlowIntegrationTest extends BaseReservationFlowTest { +class OnlineEventReservationFlowIntegrationTest extends BaseReservationFlowTest { private final OrganizationRepository organizationRepository; private final UserManager userManager; diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowAuthenticatedUserIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowAuthenticatedUserIntegrationTest.java index d2e62c8409..d661a13e87 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowAuthenticatedUserIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowAuthenticatedUserIntegrationTest.java @@ -47,6 +47,7 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; @@ -69,11 +70,10 @@ import static alfio.test.util.IntegrationTestUtil.initEvent; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class ReservationFlowAuthenticatedUserIntegrationTest extends BaseReservationFlowTest { +class ReservationFlowAuthenticatedUserIntegrationTest extends BaseReservationFlowTest { private final OrganizationRepository organizationRepository; private final UserManager userManager; diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowIntegrationTest.java index 86a7caa84e..2c4dc0f81e 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowIntegrationTest.java @@ -44,6 +44,7 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; @@ -64,10 +65,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class ReservationFlowIntegrationTest extends BaseReservationFlowTest { private final OrganizationRepository organizationRepository; diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowWithSubscriptionIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowWithSubscriptionIntegrationTest.java index 9402f2aaba..efc6d354b4 100644 --- a/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowWithSubscriptionIntegrationTest.java +++ b/src/test/java/alfio/controller/api/v2/user/reservation/ReservationFlowWithSubscriptionIntegrationTest.java @@ -50,6 +50,7 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import alfio.util.Json; @@ -79,10 +80,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class ReservationFlowWithSubscriptionIntegrationTest extends BaseReservationFlowTest { private final OrganizationRepository organizationRepository; diff --git a/src/test/java/alfio/controller/api/v2/user/reservation/RetryConfirmationFlowIntegrationTest.java b/src/test/java/alfio/controller/api/v2/user/reservation/RetryConfirmationFlowIntegrationTest.java new file mode 100644 index 0000000000..53f0d5cfde --- /dev/null +++ b/src/test/java/alfio/controller/api/v2/user/reservation/RetryConfirmationFlowIntegrationTest.java @@ -0,0 +1,399 @@ +/** + * 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.ContactAndTicketsForm; +import alfio.controller.form.PaymentForm; +import alfio.controller.payment.api.stripe.StripePaymentWebhookController; +import alfio.extension.Extension; +import alfio.extension.ExtensionService; +import alfio.manager.*; +import alfio.manager.support.extension.ExtensionEvent; +import alfio.manager.system.AdminJobExecutor; +import alfio.manager.system.AdminJobManager; +import alfio.manager.system.AdminJobManagerInvoker; +import alfio.manager.user.UserManager; +import alfio.model.Event; +import alfio.model.ExtensionLog; +import alfio.model.TicketCategory; +import alfio.model.TicketReservation; +import alfio.model.metadata.AlfioMetadata; +import alfio.model.metadata.TicketMetadataContainer; +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.AdminJobQueueRepository; +import alfio.repository.system.ConfigurationRepository; +import alfio.repository.user.OrganizationRepository; +import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; +import alfio.util.ClockProvider; +import com.stripe.net.Webhook; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +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.util.LinkedMultiValueMap; +import org.springframework.validation.BeanPropertyBindingResult; + +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Stream; + +import static alfio.controller.api.v2.user.reservation.StripeReservationFlowIntegrationTest.WEBHOOK_SECRET; +import static alfio.test.util.IntegrationTestUtil.*; +import static alfio.util.HttpUtils.APPLICATION_JSON; +import static alfio.util.HttpUtils.APPLICATION_JSON_UTF8; +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.*; + +@AlfioIntegrationTest +@ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) +@ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) +class RetryConfirmationFlowIntegrationTest extends BaseReservationFlowTest { + + private static final String TICKET_METADATA = "ticketMetadata"; + private static final String INVOICE_NUMBER_GENERATOR = "invoiceNumberGenerator"; + private final OrganizationRepository organizationRepository; + private final UserManager userManager; + private final AdminJobQueueRepository adminJobQueueRepository; + private final AdminJobManagerInvoker adminJobManagerInvoker; + private final StripePaymentWebhookController stripePaymentWebhookController; + + @Autowired + public RetryConfirmationFlowIntegrationTest(ConfigurationRepository configurationRepository, + EventManager eventManager, + EventRepository eventRepository, + 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, + ClockProvider clockProvider, + NotificationManager notificationManager, + UserRepository userRepository, + OrganizationDeleter organizationDeleter, + PromoCodeDiscountRepository promoCodeDiscountRepository, + PromoCodeRequestManager promoCodeRequestManager, + ExportManager exportManager, + OrganizationRepository organizationRepository, + UserManager userManager, + AdminJobQueueRepository adminJobQueueRepository, + AdminJobManager adminJobManager, + 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, + exportManager); + this.organizationRepository = organizationRepository; + this.userManager = userManager; + this.adminJobQueueRepository = adminJobQueueRepository; + this.adminJobManagerInvoker = new AdminJobManagerInvoker(adminJobManager); + this.stripePaymentWebhookController = stripePaymentWebhookController; + } + + @BeforeEach + void setUp() { + 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(boolean invoiceExtensionFailure) { + 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); + return new CustomReservationFlowContext(eventAndUser.getLeft(), eventAndUser.getRight() + "_owner", invoiceExtensionFailure); + } + + @Test + void invoiceExtensionFailure() throws Exception { + insertOrUpdateExtension("/retry-reservation/fail-invoice-number-generator.js", INVOICE_NUMBER_GENERATOR, false); + insertOrUpdateExtension("/retry-reservation/success-ticket-metadata.js", TICKET_METADATA, false); + super.testBasicFlow(() -> createContext(true)); + } + + @Test + void metadataFailure() throws Exception { + insertOrUpdateExtension("/retry-reservation/success-invoice-number-generator.js", INVOICE_NUMBER_GENERATOR, false); + insertOrUpdateExtension("/retry-reservation/fail-ticket-metadata.js", TICKET_METADATA, false); + super.testBasicFlow(() -> createContext(false)); + } + + @Override + protected void ensureReservationIsComplete(String reservationId, ReservationFlowContext context) { + var ctx = (CustomReservationFlowContext) context; + checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.FINALIZING, context); + + // check that the IndexController is redirecting properly + var shortName = context.event.getShortName(); + var redirect = indexController.redirectEventToReservation(shortName, reservationId, null); + assertEquals("redirect:/event/"+shortName+"/reservation/"+reservationId+"/success", redirect); + + var reservation = ticketReservationRepository.findReservationById(reservationId); + if (ctx.invoiceExtensionFailure) { + // in this case the transaction must have been rolled back completely + assertNull(reservation.getInvoiceNumber()); + } else { + assertEquals("ABCD", reservation.getInvoiceNumber()); + } + // transaction must be present + var tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, PaymentMethod.CREDIT_CARD.name()); + assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + assertTrue(requireNonNull(tStatus.getBody()).isSuccess()); + // check that the confirmation has been rescheduled + var now = ZonedDateTime.now(clockProvider.getClock()).plusSeconds(3); + var schedules = adminJobQueueRepository.loadPendingSchedules(Set.of(AdminJobExecutor.JobName.RETRY_RESERVATION_CONFIRMATION.name()), now); + assertEquals(1, schedules.size()); + + // fix the error, then trigger reschedule + if (ctx.invoiceExtensionFailure) { + insertOrUpdateExtension("/retry-reservation/success-invoice-number-generator.js", INVOICE_NUMBER_GENERATOR, true); + } else { + insertOrUpdateExtension("/retry-reservation/success-ticket-metadata.js", TICKET_METADATA, true); + } + adminJobManagerInvoker.invokeProcessPendingReservationsRetry(now); + checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.COMPLETE, context); + reservation = ticketReservationRepository.findReservationById(reservationId); + assertEquals("ABCD", reservation.getInvoiceNumber()); + ticketRepository.findTicketsInReservation(reservationId) + .forEach(t -> { + var metadata = ticketRepository.getTicketMetadata(t.getId()).getMetadataForKey(TicketMetadataContainer.GENERAL); + assertTrue(metadata.isPresent()); + assertEquals(t.getUuid(), metadata.get().getAttributes().get("uuid")); + }); + } + + @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 resInfoResponse = reservationApiV2Controller.getReservationInfo(reservationId, null); + assertEquals(TicketReservation.TicketReservationStatus.EXTERNAL_PROCESSING_PAYMENT, Objects.requireNonNull(resInfoResponse.getBody()).getStatus()); + + // + 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(reservationId); + + // status must be FINALIZING + checkStatus(reservationId, HttpStatus.OK, true, TicketReservation.TicketReservationStatus.FINALIZING, 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); + } + + private void processWebHook(String reservationId) { + try { + var resource = getClass().getResource("/transaction-json/stripe-success-valid.json"); + 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(StandardCharsets.UTF_8)); + httpRequest.setContentType(APPLICATION_JSON); + var response = stripePaymentWebhookController.receivePaymentConfirmation(signedHeader, httpRequest); + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(APPLICATION_JSON_UTF8, Objects.requireNonNull(response.getHeaders().getContentType()).toString()); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + @Override + protected void assertEventLogged(List extLog, ExtensionEvent event, int logSize) { + super.assertEventLogged(extLog, event); + } + + @Override + protected void customizeContactFormForSuccessfulReservation(ContactAndTicketsForm contactForm) { + contactForm.setInvoiceRequested(true); + contactForm.setBillingAddressCity("City"); + contactForm.setBillingAddressCompany("Company"); + contactForm.setBillingAddressZip("0000"); + contactForm.setBillingAddressLine1("address"); + contactForm.setAddCompanyBillingDetails(true); + contactForm.setVatCountryCode("CH"); + contactForm.setVatNr("1234567"); + } + + private void insertOrUpdateExtension(String filePath, String name, boolean update) { + try (var extensionInputStream = requireNonNull(RetryConfirmationFlowIntegrationTest.class.getResourceAsStream(filePath))) { + List extensionStream = IOUtils.readLines(new InputStreamReader(extensionInputStream, StandardCharsets.UTF_8)); + String concatenation = String.join("\n", extensionStream); + String previousPath = update ? "-" : null; + String previousName = update ? name : null; + extensionService.createOrUpdate(previousPath, previousName, new Extension("-", name, concatenation, true)); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + @Override + protected Stream getExtensionEventsToRegister() { + return EnumSet.complementOf(EnumSet.of(ExtensionEvent.INVOICE_GENERATION, ExtensionEvent.TICKET_ASSIGNED_GENERATE_METADATA)) + .stream() + .map(ee -> "'"+ee.name()+"'"); + } + + @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()); + } + + static class CustomReservationFlowContext extends ReservationFlowContext { + + private final boolean invoiceExtensionFailure; + CustomReservationFlowContext(Event event, String userId, boolean invoiceExtensionFailure) { + super(event, userId); + this.invoiceExtensionFailure = invoiceExtensionFailure; + } + } +} 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 fecdcf0292..0bd7450eeb 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 @@ -51,35 +51,26 @@ import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; -import alfio.util.BaseIntegrationTest; +import alfio.test.util.AlfioIntegrationTest; 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.charset.StandardCharsets; 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; @@ -88,19 +79,17 @@ import java.util.Objects; 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 +@AlfioIntegrationTest @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"; + public static final String WEBHOOK_SECRET = "WEBHOOK_SECRET"; private static final String PAYLOAD_FILENAME = "payloadFilename"; private final OrganizationRepository organizationRepository; private final UserManager userManager; @@ -242,6 +231,9 @@ protected void performAndValidatePayment(ReservationFlowContext context, tStatus = reservationApiV2Controller.getTransactionStatus(reservationId, PaymentMethod.CREDIT_CARD.name()); assertEquals(HttpStatus.OK, tStatus.getStatusCode()); + var resInfoResponse = reservationApiV2Controller.getReservationInfo(reservationId, null); + assertEquals(TicketReservation.TicketReservationStatus.EXTERNAL_PROCESSING_PAYMENT, Objects.requireNonNull(resInfoResponse.getBody()).getStatus()); + // var promoCodeUsage = promoCodeRequestManager.retrieveDetailedUsage(promoCodeId, context.event.getId()); assertTrue(promoCodeUsage.isEmpty()); diff --git a/src/test/java/alfio/e2e/NormalFlowE2ETest.java b/src/test/java/alfio/e2e/NormalFlowE2ETest.java index 188af58b12..0da6e5b7ce 100644 --- a/src/test/java/alfio/e2e/NormalFlowE2ETest.java +++ b/src/test/java/alfio/e2e/NormalFlowE2ETest.java @@ -18,6 +18,7 @@ import alfio.BaseTestConfiguration; import alfio.config.Initializer; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import alfio.util.HttpUtils; @@ -74,8 +75,8 @@ */ @ContextConfiguration(classes = { BaseTestConfiguration.class, NormalFlowE2ETest.E2EConfiguration.class }) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@SpringBootTest -public class NormalFlowE2ETest extends BaseIntegrationTest { +@AlfioIntegrationTest +class NormalFlowE2ETest extends BaseIntegrationTest { private static final Logger LOGGER = LoggerFactory.getLogger(NormalFlowE2ETest.class); private static final String JSON_BODY; diff --git a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java index 2bf87604a3..5231d7bfc9 100644 --- a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java +++ b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java @@ -37,6 +37,7 @@ import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -62,10 +63,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class AssignTicketToSubscriberJobExecutorIntegrationTest { private static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); diff --git a/src/test/java/alfio/job/executor/RetryFailedExtensionJobExecutorTest.java b/src/test/java/alfio/job/executor/RetryFailedExtensionJobExecutorTest.java index 6461181855..d24b68e725 100644 --- a/src/test/java/alfio/job/executor/RetryFailedExtensionJobExecutorTest.java +++ b/src/test/java/alfio/job/executor/RetryFailedExtensionJobExecutorTest.java @@ -36,6 +36,7 @@ import alfio.repository.system.AdminJobQueueRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.ClockProvider; import org.apache.commons.io.IOUtils; @@ -63,10 +64,9 @@ import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class RetryFailedExtensionJobExecutorTest { private static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); diff --git a/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java b/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java index 4d5066134a..239042e98d 100644 --- a/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java @@ -31,6 +31,7 @@ import alfio.repository.*; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -59,11 +60,10 @@ import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class AdminReservationManagerIntegrationTest extends BaseIntegrationTest { +class AdminReservationManagerIntegrationTest extends BaseIntegrationTest { @Autowired private AdminReservationManager adminReservationManager; diff --git a/src/test/java/alfio/manager/CheckInManagerIntegrationTest.java b/src/test/java/alfio/manager/CheckInManagerIntegrationTest.java index 63df114e00..62238a8fee 100644 --- a/src/test/java/alfio/manager/CheckInManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/CheckInManagerIntegrationTest.java @@ -32,6 +32,7 @@ import alfio.repository.TicketRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.ClockProvider; import org.apache.commons.lang3.time.DateUtils; @@ -57,10 +58,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class CheckInManagerIntegrationTest { @Autowired diff --git a/src/test/java/alfio/manager/ConfigurationManagerIntegrationTest.java b/src/test/java/alfio/manager/ConfigurationManagerIntegrationTest.java index 617880c289..3c233694c5 100644 --- a/src/test/java/alfio/manager/ConfigurationManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/ConfigurationManagerIntegrationTest.java @@ -41,6 +41,7 @@ import alfio.repository.TicketCategoryRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.junit.jupiter.api.BeforeEach; @@ -65,11 +66,10 @@ import static org.mockito.Mockito.when; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class ConfigurationManagerIntegrationTest extends BaseIntegrationTest { +class ConfigurationManagerIntegrationTest extends BaseIntegrationTest { public static final String USERNAME = "test"; diff --git a/src/test/java/alfio/manager/EventManagerIntegrationTest.java b/src/test/java/alfio/manager/EventManagerIntegrationTest.java index 8b0f00eb65..9708d8b7af 100644 --- a/src/test/java/alfio/manager/EventManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/EventManagerIntegrationTest.java @@ -31,6 +31,7 @@ import alfio.repository.*; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.time.DateUtils; @@ -54,10 +55,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class EventManagerIntegrationTest extends BaseIntegrationTest { @Autowired diff --git a/src/test/java/alfio/manager/EventNameManagerIntegrationTest.java b/src/test/java/alfio/manager/EventNameManagerIntegrationTest.java index dee8fc2243..d399379c95 100644 --- a/src/test/java/alfio/manager/EventNameManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/EventNameManagerIntegrationTest.java @@ -29,6 +29,7 @@ import alfio.repository.EventRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.junit.jupiter.api.BeforeEach; @@ -52,11 +53,10 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class EventNameManagerIntegrationTest extends BaseIntegrationTest { +class EventNameManagerIntegrationTest extends BaseIntegrationTest { @Autowired private EventRepository eventRepository; diff --git a/src/test/java/alfio/manager/ExtensionManagerIntegrationTest.java b/src/test/java/alfio/manager/ExtensionManagerIntegrationTest.java index d342ccfa0f..449e66c406 100644 --- a/src/test/java/alfio/manager/ExtensionManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/ExtensionManagerIntegrationTest.java @@ -36,6 +36,7 @@ import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -66,10 +67,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class ExtensionManagerIntegrationTest { @Autowired diff --git a/src/test/java/alfio/manager/FileUploadManagerIntegrationTest.java b/src/test/java/alfio/manager/FileUploadManagerIntegrationTest.java index d2b51adde4..c0e962f2c9 100644 --- a/src/test/java/alfio/manager/FileUploadManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/FileUploadManagerIntegrationTest.java @@ -22,6 +22,7 @@ import alfio.config.Initializer; import alfio.model.FileBlobMetadata; import alfio.model.modification.UploadBase64FileModification; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import org.apache.commons.lang3.time.DateUtils; import org.junit.jupiter.api.Test; @@ -38,11 +39,10 @@ import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class FileUploadManagerIntegrationTest extends BaseIntegrationTest { +@AlfioIntegrationTest +class FileUploadManagerIntegrationTest extends BaseIntegrationTest { @Autowired FileUploadManager fileUploadManager; diff --git a/src/test/java/alfio/manager/GroupManagerIntegrationTest.java b/src/test/java/alfio/manager/GroupManagerIntegrationTest.java index 1ae8de0797..dc071d2679 100644 --- a/src/test/java/alfio/manager/GroupManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/GroupManagerIntegrationTest.java @@ -35,6 +35,7 @@ import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -57,11 +58,10 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class GroupManagerIntegrationTest extends BaseIntegrationTest { +class GroupManagerIntegrationTest extends BaseIntegrationTest { @Autowired private EventManager eventManager; diff --git a/src/test/java/alfio/manager/I18nManagerIntegrationTest.java b/src/test/java/alfio/manager/I18nManagerIntegrationTest.java index 774a580cd8..c06986761b 100644 --- a/src/test/java/alfio/manager/I18nManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/I18nManagerIntegrationTest.java @@ -22,6 +22,7 @@ import alfio.manager.i18n.I18nManager; import alfio.manager.i18n.MessageSourceManager; import alfio.model.ContentLanguage; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -37,11 +38,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class I18nManagerIntegrationTest extends BaseIntegrationTest { +class I18nManagerIntegrationTest extends BaseIntegrationTest { private static final ZonedDateTime DATE = ZonedDateTime.of(1999, 2, 3, 4, 5, 6, 7, ZoneId.of("Europe/Zurich")); diff --git a/src/test/java/alfio/manager/ReverseChargeManagerIntegrationTest.java b/src/test/java/alfio/manager/ReverseChargeManagerIntegrationTest.java index d3fb049584..c34745abb2 100644 --- a/src/test/java/alfio/manager/ReverseChargeManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/ReverseChargeManagerIntegrationTest.java @@ -41,6 +41,7 @@ import alfio.repository.TicketRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -70,11 +71,10 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class, ControllerConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class ReverseChargeManagerIntegrationTest extends BaseIntegrationTest { +class ReverseChargeManagerIntegrationTest extends BaseIntegrationTest { private final ClockProvider clockProvider; private final OrganizationRepository organizationRepository; diff --git a/src/test/java/alfio/manager/SubscriptionManagerIntegrationTest.java b/src/test/java/alfio/manager/SubscriptionManagerIntegrationTest.java index de706c30fd..fbf3b90833 100644 --- a/src/test/java/alfio/manager/SubscriptionManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/SubscriptionManagerIntegrationTest.java @@ -35,6 +35,7 @@ import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -62,10 +63,9 @@ import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional class SubscriptionManagerIntegrationTest { @Autowired diff --git a/src/test/java/alfio/manager/TicketReservationManagerConcurrentTest.java b/src/test/java/alfio/manager/TicketReservationManagerConcurrentTest.java index b8acbb3a4c..5e51ba1c91 100644 --- a/src/test/java/alfio/manager/TicketReservationManagerConcurrentTest.java +++ b/src/test/java/alfio/manager/TicketReservationManagerConcurrentTest.java @@ -31,6 +31,7 @@ import alfio.repository.SpecialPriceRepository; import alfio.repository.TicketCategoryRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import org.apache.commons.lang3.time.DateUtils; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.AfterEach; @@ -60,10 +61,10 @@ import static alfio.test.util.TestUtil.clockProvider; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest() +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -public class TicketReservationManagerConcurrentTest { +class TicketReservationManagerConcurrentTest { private static final String ACCESS_CODE = "MY_ACCESS_CODE"; diff --git a/src/test/java/alfio/manager/TicketReservationManagerIntegrationTest.java b/src/test/java/alfio/manager/TicketReservationManagerIntegrationTest.java index 421487bf8c..1d6bca8aff 100644 --- a/src/test/java/alfio/manager/TicketReservationManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/TicketReservationManagerIntegrationTest.java @@ -21,6 +21,8 @@ import alfio.config.Initializer; import alfio.manager.payment.PaymentSpecification; import alfio.manager.support.PaymentResult; +import alfio.manager.support.reservation.NotEnoughTicketsException; +import alfio.manager.support.reservation.TooManyTicketsForDiscountCodeException; import alfio.manager.user.UserManager; import alfio.model.*; import alfio.model.metadata.AlfioMetadata; @@ -30,6 +32,7 @@ import alfio.repository.*; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -61,11 +64,10 @@ import static alfio.test.util.IntegrationTestUtil.initEvent; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class TicketReservationManagerIntegrationTest extends BaseIntegrationTest { +class TicketReservationManagerIntegrationTest extends BaseIntegrationTest { static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); private static final String ACCESS_CODE = "MYACCESSCODE"; @@ -367,7 +369,7 @@ public void testTicketWithDiscount() { trTooMuch.setAmount(4); trTooMuch.setTicketCategoryId(unbounded.getId()); TicketReservationWithOptionalCodeModification modTooMuch = new TicketReservationWithOptionalCodeModification(trTooMuch, Optional.empty()); - assertThrows(TicketReservationManager.TooManyTicketsForDiscountCodeException.class, + assertThrows(TooManyTicketsForDiscountCodeException.class, () -> ticketReservationManager.createTicketReservation(event, Collections.singletonList(modTooMuch ), Collections.emptyList(), DateUtils.addDays(new Date(), 1), Optional.of("MYPROMOCODE"), Locale.ENGLISH, false, null)); } @@ -472,7 +474,7 @@ public void testAccessCodeLimit() { trTooMuch.setAmount(1); trTooMuch.setTicketCategoryId(triple.getMiddle().getId()); TicketReservationWithOptionalCodeModification modTooMuch = new TicketReservationWithOptionalCodeModification(trTooMuch, Optional.empty()); - assertThrows(TicketReservationManager.TooManyTicketsForDiscountCodeException.class, + assertThrows(TooManyTicketsForDiscountCodeException.class, () -> ticketReservationManager.createTicketReservation(triple.getLeft(), List.of(modTooMuch), Collections.emptyList(), DateUtils.addDays(new Date(), 1), Optional.of(ACCESS_CODE), Locale.ENGLISH, false, null)); } @@ -555,7 +557,7 @@ public void testTicketSelectionNotEnoughTicketsAvailable() { tr.setTicketCategoryId(unbounded.getId()); TicketReservationWithOptionalCodeModification mod = new TicketReservationWithOptionalCodeModification(tr, Optional.empty()); - assertThrows(TicketReservationManager.NotEnoughTicketsException.class, () -> ticketReservationManager.createTicketReservation(event, Collections.singletonList(mod), Collections.emptyList(), DateUtils.addDays(new Date(), 1), Optional.empty(), Locale.ENGLISH, false, null)); + assertThrows(NotEnoughTicketsException.class, () -> ticketReservationManager.createTicketReservation(event, Collections.singletonList(mod), Collections.emptyList(), DateUtils.addDays(new Date(), 1), Optional.empty(), Locale.ENGLISH, false, null)); } @Test diff --git a/src/test/java/alfio/manager/TicketReservationManagerTest.java b/src/test/java/alfio/manager/TicketReservationManagerTest.java index 258ef811d2..e175484ca7 100644 --- a/src/test/java/alfio/manager/TicketReservationManagerTest.java +++ b/src/test/java/alfio/manager/TicketReservationManagerTest.java @@ -21,10 +21,10 @@ import alfio.manager.PaymentManager.PaymentMethodDTO.PaymentMethodStatus; import alfio.manager.i18n.MessageSourceManager; import alfio.manager.payment.*; -import alfio.manager.support.PartialTicketTextGenerator; -import alfio.manager.support.PaymentResult; -import alfio.manager.support.PaymentWebhookResult; -import alfio.manager.support.TemplateGenerator; +import alfio.manager.support.*; +import alfio.manager.support.reservation.OrderSummaryGenerator; +import alfio.manager.support.reservation.ReservationCostCalculator; +import alfio.manager.support.reservation.ReservationEmailContentHelper; import alfio.manager.system.ConfigurationManager; import alfio.manager.system.ConfigurationManager.MaybeConfiguration; import alfio.manager.testSupport.MaybeConfigurationBuilder; @@ -35,6 +35,7 @@ import alfio.model.modification.TicketReservationWithOptionalCodeModification; import alfio.model.system.ConfigurationKeyValuePathLevel; import alfio.model.system.ConfigurationKeys; +import alfio.model.system.command.FinalizeReservation; import alfio.model.transaction.PaymentContext; import alfio.model.transaction.PaymentMethod; import alfio.model.transaction.PaymentProxy; @@ -45,16 +46,19 @@ import alfio.model.user.Organization; import alfio.model.user.Role; import alfio.repository.*; +import alfio.repository.system.AdminJobQueueRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; import alfio.test.util.TestUtil; import alfio.util.*; import ch.digitalfondue.npjt.AffectedRowCountAndKey; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.SqlParameterSource; @@ -139,6 +143,11 @@ BANK_ACCOUNT_NR, new MaybeConfiguration(BANK_ACCOUNT_NR), BANK_ACCOUNT_OWNER, new MaybeConfiguration(BANK_ACCOUNT_OWNER)); private PromoCodeDiscountRepository promoCodeDiscountRepository; private BillingDocumentManager billingDocumentManager; + private ApplicationEventPublisher applicationEventPublisher; + private ReservationEmailContentHelper reservationHelper; + private ReservationFinalizer reservationFinalizer; + private ReservationCostCalculator reservationCostCalculator; + private ReservationMetadata metadata; @BeforeEach void init() { @@ -210,6 +219,15 @@ void init() { when(purchaseContextManager.findByReservationId(anyString())).thenReturn(Optional.of(event)); billingDocumentManager = mock(BillingDocumentManager.class); + applicationEventPublisher = mock(ApplicationEventPublisher.class); + reservationHelper = mock(ReservationEmailContentHelper.class); + reservationCostCalculator = mock(ReservationCostCalculator.class); + var osm = mock(OrderSummaryGenerator.class); + reservationFinalizer = new ReservationFinalizer(transactionManager, + ticketReservationRepository, userRepository, extensionManager, auditingRepository, TestUtil.clockProvider(), + configurationManager, null, ticketRepository, reservationHelper, specialPriceRepository, + waitingQueueManager, ticketCategoryRepository, reservationCostCalculator, billingDocumentManager, additionalServiceItemRepository, + osm, transactionRepository, mock(AdminJobQueueRepository.class), purchaseContextManager, mock(Json.class)); trm = new TicketReservationManager(eventRepository, organizationRepository, ticketRepository, @@ -242,7 +260,12 @@ void init() { TestUtil.clockProvider(), purchaseContextManager, mock(SubscriptionRepository.class), - mock(UserManager.class)); + mock(UserManager.class), + applicationEventPublisher, + reservationCostCalculator, + reservationHelper, + reservationFinalizer, + osm); when(event.getId()).thenReturn(EVENT_ID); when(event.getOrganizationId()).thenReturn(ORGANIZATION_ID); @@ -277,6 +300,9 @@ void init() { when(messageSource.getMessage(eq("reminder.ticket-not-assigned.subject"), any(), any())).thenReturn("subject"); when(billingDocumentRepository.insert(anyInt(), anyString(), anyString(), any(), anyString(), any(), anyInt())).thenReturn(new AffectedRowCountAndKey<>(1, 1L)); totalPrice = mock(TotalPrice.class); + metadata = mock(ReservationMetadata.class); + when(ticketReservationRepository.getMetadata(RESERVATION_ID)).thenReturn(metadata); + when(metadata.isFinalized()).thenReturn(true); } private void initUpdateTicketOwner(Ticket original, Ticket modified, String ticketId, String originalEmail, String originalName, UpdateTicketOwnerForm form) { @@ -393,7 +419,7 @@ void fallbackToCurrentLocale() { when(original.getUserLanguage()).thenReturn(USER_LANGUAGE); trm.updateTicketOwner(original, Locale.ENGLISH, event, form, (a) -> null, ownerChangeTextBuilder, Optional.empty()); verify(messageSource, times(1)).getMessage(eq("ticket-has-changed-owner-subject"), any(), eq(Locale.ITALIAN)); - verify(notificationManager, times(1)).sendTicketByEmail(eq(modified), eq(event), eq(Locale.ENGLISH), any(), any(), any(), any()); + verify(reservationHelper).sendTicketByEmail(eq(modified), eq(Locale.ENGLISH), eq(event), any(PartialTicketTextGenerator.class)); verify(notificationManager, times(1)).sendSimpleEmail(eq(event), eq(RESERVATION_ID), eq(originalEmail), anyString(), any(TemplateGenerator.class)); } @@ -786,7 +812,7 @@ void confirmPaidReservation() { when(configurationManager.getFor(eq(BANKING_KEY), any())).thenReturn(BANKING_INFO); mockBillingDocument(); testPaidReservation(true, true); - verify(notificationManager).sendTicketByEmail(any(), any(), any(), any(), any(), any(), any()); + verify(notificationManager, never()).sendTicketByEmail(any(), any(), any(), any(), any(), any(), any()); } @Test @@ -884,11 +910,15 @@ private void testPaidReservation(boolean enableTicketTransfer, boolean expectSuc var verificationMode = expectCompleteReservation ? times(1) : never(); verify(ticketReservationRepository, verificationMode).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.IN_PAYMENT.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), anyString(), any(), eq(PaymentProxy.STRIPE.toString()), isNull()); - verify(ticketRepository, verificationMode).updateTicketsStatusWithReservationId(eq(RESERVATION_ID), eq(TicketStatus.ACQUIRED.toString())); - verify(specialPriceRepository, verificationMode).updateStatusForReservation(eq(singletonList(RESERVATION_ID)), eq(SpecialPrice.Status.TAKEN.toString())); - verify(ticketReservationRepository, verificationMode).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.COMPLETE.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), anyString(), any(), eq(PaymentProxy.STRIPE.toString()), isNull()); - verify(waitingQueueManager, verificationMode).fireReservationConfirmed(eq(RESERVATION_ID)); - verify(billingDocumentManager, verificationMode).generateInvoiceNumber(eq(spec), any()); + verify(billingDocumentManager, never()).generateInvoiceNumber(eq(spec), any()); + verify(specialPriceRepository, verificationMode).updateStatusForReservation(singletonList(RESERVATION_ID), SpecialPrice.Status.TAKEN.toString()); + + if (expectCompleteReservation) { + verify(applicationEventPublisher).publishEvent(new FinalizeReservation(spec, PaymentProxy.STRIPE, true, true, null, PENDING)); + } + verify(ticketRepository, never()).updateTicketsStatusWithReservationId(RESERVATION_ID, TicketStatus.ACQUIRED.toString()); + verify(ticketReservationRepository, never()).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.COMPLETE.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), anyString(), any(), eq(PaymentProxy.STRIPE.toString()), isNull()); + verify(waitingQueueManager, never()).fireReservationConfirmed(RESERVATION_ID); verify(ticketReservationRepository, never()).setInvoiceNumber(eq(RESERVATION_ID), any()); } else { Assertions.assertFalse(result.isSuccessful()); @@ -910,16 +940,17 @@ void returnFailureCodeIfPaymentNotSuccessful() { when(paymentManager.streamActiveProvidersByProxy(eq(PaymentProxy.STRIPE), any())).thenReturn(Stream.of(stripeCreditCardManager)); when(stripeCreditCardManager.getTokenAndPay(any())).thenReturn(PaymentResult.failed("error-code")); when(stripeCreditCardManager.accept(eq(PaymentMethod.CREDIT_CARD), any(), any())).thenReturn(true); + when(ticketReservationRepository.findOptionalStatusAndValidationById(RESERVATION_ID)).thenReturn(Optional.of(new TicketReservationStatusAndValidation(PENDING, true))); PaymentSpecification spec = new PaymentSpecification(RESERVATION_ID, new StripeCreditCardToken(GATEWAY_TOKEN), 100, event, "email@user", new CustomerName("Full Name", null, null, event.mustUseFirstAndLastName()), null, null, Locale.ENGLISH, true, false, null, "IT", "12345", PriceContainer.VatStatus.INCLUDED, true, false); PaymentResult result = trm.performPayment(spec, new TotalPrice(100, 0, 0, 0, "CHF"), PaymentProxy.STRIPE, PaymentMethod.CREDIT_CARD, null); Assertions.assertFalse(result.isSuccessful()); Assertions.assertFalse(result.getGatewayId().isPresent()); Assertions.assertEquals(Optional.of("error-code"), result.getErrorCode()); verify(ticketReservationRepository).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.IN_PAYMENT.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), isNull(), isNull(), eq(PaymentProxy.STRIPE.toString()), isNull()); - verify(ticketReservationRepository).findReservationByIdForUpdate(eq(RESERVATION_ID)); - verify(ticketReservationRepository).updateReservationStatus(eq(RESERVATION_ID), eq(TicketReservationStatus.PENDING.toString())); - verify(configurationManager, never()).hasAllConfigurationsForInvoice(eq(event)); - verify(ticketReservationRepository).updateBillingData(eq(PriceContainer.VatStatus.INCLUDED), eq(100), eq(100), eq(0), eq(0), eq(EVENT_CURRENCY), eq("12345"), eq("IT"), eq(true), eq(RESERVATION_ID)); + verify(ticketReservationRepository).findReservationByIdForUpdate(RESERVATION_ID); + verify(ticketReservationRepository).updateReservationStatus(RESERVATION_ID, TicketReservationStatus.PENDING.toString()); + verify(configurationManager, never()).hasAllConfigurationsForInvoice(event); + verify(ticketReservationRepository).updateBillingData(PriceContainer.VatStatus.INCLUDED, 100, 100, 0, 0, EVENT_CURRENCY, "12345", "IT", true, RESERVATION_ID); } @Test @@ -945,13 +976,15 @@ void handleOnSitePaymentMethod() { PaymentResult result = trm.performPayment(spec, new TotalPrice(100, 0, 0, 0, "CHF"), PaymentProxy.ON_SITE, PaymentMethod.ON_SITE, null); Assertions.assertTrue(result.isSuccessful()); Assertions.assertEquals(Optional.of(TicketReservationManager.NOT_YET_PAID_TRANSACTION_ID), result.getGatewayId()); - verify(ticketReservationRepository).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.COMPLETE.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), anyString(), any(), eq(PaymentProxy.ON_SITE.toString()), isNull()); + verify(specialPriceRepository).updateStatusForReservation(singletonList(RESERVATION_ID), SpecialPrice.Status.TAKEN.toString()); + verify(applicationEventPublisher).publishEvent(new FinalizeReservation(spec, PaymentProxy.ON_SITE, true, true, null, PENDING)); + verify(ticketRepository, never()).updateTicketsStatusWithReservationId(RESERVATION_ID, TicketStatus.ACQUIRED.toString()); + verify(ticketReservationRepository, never()).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.COMPLETE.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), anyString(), any(), eq(PaymentProxy.ON_SITE.toString()), isNull()); + verify(waitingQueueManager, never()).fireReservationConfirmed(RESERVATION_ID); + verify(ticketReservationRepository, never()).setInvoiceNumber(eq(RESERVATION_ID), any()); verify(ticketReservationRepository).findReservationByIdForUpdate(eq(RESERVATION_ID)); - verify(ticketRepository).updateTicketsStatusWithReservationId(eq(RESERVATION_ID), eq(TicketStatus.TO_BE_PAID.toString())); - verify(specialPriceRepository).updateStatusForReservation(eq(singletonList(RESERVATION_ID)), eq(SpecialPrice.Status.TAKEN.toString())); - verify(waitingQueueManager).fireReservationConfirmed(eq(RESERVATION_ID)); verify(ticketReservationRepository, atLeastOnce()).findReservationById(RESERVATION_ID); - verify(billingDocumentManager).generateInvoiceNumber(eq(spec), any()); + verify(billingDocumentManager, never()).generateInvoiceNumber(eq(spec), any()); verify(ticketReservationRepository).updateBillingData(eq(PriceContainer.VatStatus.INCLUDED), eq(100), eq(100), eq(0), eq(0), eq(EVENT_CURRENCY), eq("123456"), eq("IT"), eq(true), eq(RESERVATION_ID)); verify(ticketRepository, atLeastOnce()).findTicketsInReservation(anyString()); } @@ -977,8 +1010,8 @@ void handleOfflinePaymentMethod() { Assertions.assertEquals(Optional.of(TicketReservationManager.NOT_YET_PAID_TRANSACTION_ID), result.getGatewayId()); verify(waitingQueueManager, never()).fireReservationConfirmed(eq(RESERVATION_ID)); verify(ticketReservationRepository).findReservationByIdForUpdate(eq(RESERVATION_ID)); - verify(billingDocumentManager).generateInvoiceNumber(eq(spec), any()); - verify(ticketReservationRepository).setInvoiceNumber(RESERVATION_ID, invoiceNumber); + verify(billingDocumentManager, never()).generateInvoiceNumber(eq(spec), any()); + verify(ticketReservationRepository, never()).setInvoiceNumber(RESERVATION_ID, invoiceNumber); verify(ticketReservationRepository).updateBillingData(eq(PriceContainer.VatStatus.INCLUDED), eq(100), eq(100), eq(0), eq(0), eq(EVENT_CURRENCY), eq("123456"), eq("IT"), eq(true), eq(RESERVATION_ID)); } @@ -1023,18 +1056,20 @@ void confirmOfflinePayments() { when(json.fromJsonString(anyString(), eq(OrderSummary.class))).thenReturn(mock(OrderSummary.class)); when(json.asJsonString(any())).thenReturn("{}"); when(configurationManager.getFor(eq(EnumSet.of(DEFERRED_BANK_TRANSFER_ENABLED, DEFERRED_BANK_TRANSFER_SEND_CONFIRMATION_EMAIL)), any())).thenReturn(Map.of(DEFERRED_BANK_TRANSFER_ENABLED, new MaybeConfiguration(DEFERRED_BANK_TRANSFER_ENABLED))); - + when(reservationCostCalculator.totalReservationCostWithVAT(RESERVATION_ID)).thenReturn(Pair.of(new TotalPrice(0, 0, 0, 0, "CHF"), Optional.empty())); + assertThrows(IncompatibleStateException.class, () -> trm.confirmOfflinePayment(event, RESERVATION_ID, "username")); + when(metadata.isReadyForConfirmation()).thenReturn(true); trm.confirmOfflinePayment(event, RESERVATION_ID, "username"); verify(ticketReservationRepository, atLeastOnce()).findOptionalReservationById(RESERVATION_ID); verify(ticketReservationRepository, atLeastOnce()).findReservationById(RESERVATION_ID); - verify(ticketReservationRepository).lockReservationForUpdate(eq(RESERVATION_ID)); + verify(ticketReservationRepository, times(2)).lockReservationForUpdate(eq(RESERVATION_ID)); verify(ticketReservationRepository).confirmOfflinePayment(eq(RESERVATION_ID), eq(COMPLETE.toString()), any(ZonedDateTime.class)); verify(ticketRepository).updateTicketsStatusWithReservationId(eq(RESERVATION_ID), eq(TicketStatus.ACQUIRED.toString())); verify(ticketReservationRepository).updateTicketReservation(eq(RESERVATION_ID), eq(TicketReservationStatus.COMPLETE.toString()), anyString(), anyString(), isNull(), isNull(), anyString(), isNull(), any(), eq(PaymentProxy.OFFLINE.toString()), isNull()); verify(waitingQueueManager).fireReservationConfirmed(eq(RESERVATION_ID)); verify(ticketRepository, atLeastOnce()).findTicketsInReservation(RESERVATION_ID); verify(specialPriceRepository).updateStatusForReservation(eq(singletonList(RESERVATION_ID)), eq(SpecialPrice.Status.TAKEN.toString())); - verify(configurationManager, atLeastOnce()).getShortReservationID(eq(event), any(TicketReservation.class)); + verify(reservationHelper).sendConfirmationEmail(event, copy, Locale.ENGLISH, "username"); verify(ticketRepository).countTicketsInReservation(eq(RESERVATION_ID)); verify(configurationManager).getFor(eq(PLATFORM_MODE_ENABLED), any()); } @@ -1115,6 +1150,7 @@ void reservationURLGeneration() { when(ticketReservation.getId()).thenReturn(RESERVATION_ID); when(ticketReservationRepository.findReservationById(RESERVATION_ID)).thenReturn(ticketReservation); when(ticketRepository.findByUUID(ticketId)).thenReturn(ticket); + when(ticket.getUuid()).thenReturn(ticketId); when(ticket.getUserLanguage()).thenReturn(USER_LANGUAGE); //generate the reservationUrl from RESERVATION_ID Assertions.assertEquals(BASE_URL + "event/" + shortName + "/reservation/" + RESERVATION_ID + "?lang=en", trm.reservationUrl(RESERVATION_ID)); @@ -1128,7 +1164,7 @@ void reservationURLGeneration() { //generate the ticket URL Assertions.assertEquals(BASE_URL + "event/" + shortName + "/ticket/ticketId?lang=it", trm.ticketUrl(event, ticketId)); //generate the ticket update URL - Assertions.assertEquals(BASE_URL + "event/" + shortName + "/ticket/ticketId/update?lang=it", trm.ticketUpdateUrl(event, "ticketId")); + Assertions.assertEquals(BASE_URL + "event/" + shortName + "/ticket/ticketId/update?lang=it", ReservationUtil.ticketUpdateUrl(event, ticket, configurationManager)); } @Test @@ -1348,7 +1384,7 @@ class TestCancelReservationsPendingPayment { private static final String PAYMENT_ID = "paymentId"; private final List reservationIds = List.of(EXPIRED_RESERVATION_ID, PENDING_RESERVATION_ID); private final List expiredReservationIds = List.of(EXPIRED_RESERVATION_ID); - Date now = new Date(Instant.now(ClockProvider.clock()).getEpochSecond()); + Date now = new Date(Instant.now(TestUtil.clockProvider().getClock()).getEpochSecond()); Transaction transactionMock; TicketReservation pendingReservationMock; PurchaseContext purchaseContextMock; @@ -1383,6 +1419,7 @@ void cancelExpiredReservationsPendingPaymentConfirmed() { .thenReturn(Optional.of(stripeManager)); when(stripeManager.forceTransactionCheck(eq(pendingReservationMock), eq(transactionMock), any())) .thenReturn(PaymentWebhookResult.successful(new StripeCreditCardToken(""))); + when(reservationCostCalculator.totalReservationCostWithVAT(pendingReservationMock)).thenReturn(Pair.of(new TotalPrice(0, 0, 0, 0, "CHF"), Optional.empty())); trm.cleanupExpiredReservations(now); verify(ticketReservationRepository).findExpiredReservationForUpdate(now); verify(specialPriceRepository).resetToFreeAndCleanupForReservation(expiredReservationIds); @@ -1393,7 +1430,6 @@ void cancelExpiredReservationsPendingPaymentConfirmed() { verify(ticketReservationRepository).getReservationIdAndEventId(expiredReservationIds); verify(ticketReservationRepository).findReservationsWithPendingTransaction(reservationIds); verify(ticketReservationRepository).findOptionalStatusAndValidationById(PENDING_RESERVATION_ID); - verify(ticketRepository, atLeastOnce()).findTicketsInReservation(PENDING_RESERVATION_ID); verifyNoMoreInteractions(ticketReservationRepository, specialPriceRepository, ticketRepository); } @@ -1438,10 +1474,16 @@ class TestSendReservationEmailIfNecessary { private MaybeConfiguration sendReservationEmailIfNecessary; private MaybeConfiguration sendTickets; private final String reservationEmail = "blabla@example.org"; + private ReservationFinalizer finalizer; @BeforeEach @SuppressWarnings("unchecked") void setUp() { + finalizer = new ReservationFinalizer(mock(PlatformTransactionManager.class), + ticketReservationRepository, userRepository, mock(ExtensionManager.class), auditingRepository, mock(ClockProvider.class), configurationManager, + mock(SubscriptionRepository.class), ticketRepository, reservationHelper, mock(SpecialPriceRepository.class), + waitingQueueManager, ticketCategoryRepository, mock(ReservationCostCalculator.class), billingDocumentManager, mock(AdditionalServiceItemRepository.class), + mock(OrderSummaryGenerator.class), transactionRepository, mock(AdminJobQueueRepository.class), purchaseContextManager, mock(Json.class)); sendReservationEmailIfNecessary = mock(MaybeConfiguration.class); sendTickets = mock(MaybeConfiguration.class); when(ticketReservation.getSrcPriceCts()).thenReturn(0); @@ -1457,56 +1499,65 @@ void setUp() { @Test void emailSentBecauseReservationIsNotFreeOfCharge() { when(ticketReservation.getSrcPriceCts()).thenReturn(1); - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), eq(reservationEmail), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailSentBecauseThereIsMoreThanOneTicketInTheReservation() { - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket, ticket), event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), eq(reservationEmail), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket, ticket), event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailSentBecauseTicketListIsNull() { - trm.sendConfirmationEmailIfNecessary(ticketReservation, null, event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), anyString(), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, null, event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailSentBecauseTicketListIsEmpty() { - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(), event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), anyString(), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(), event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailSentBecauseTicketHolderEmailIsDifferentFromReservation() { when(ticket.getEmail()).thenReturn("blabla2@example.org"); - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), eq(reservationEmail), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailSentBecauseFlagIsSetToFalse() { when(sendReservationEmailIfNecessary.getValueAsBooleanOrDefault()).thenReturn(false); - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), eq(reservationEmail), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailSentBecauseTicketIsNotSent() { when(sendReservationEmailIfNecessary.getValueAsBooleanOrDefault()).thenReturn(true); when(sendTickets.getValueAsBooleanOrDefault()).thenReturn(false); - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); - verify(notificationManager).sendSimpleEmail(eq(event), anyString(), eq(reservationEmail), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); + verify(reservationHelper).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } @Test void emailNOTSentBecauseFlagIsSetToTrue() { when(sendReservationEmailIfNecessary.getValueAsBooleanOrDefault()).thenReturn(true); when(sendTickets.getValueAsBooleanOrDefault()).thenReturn(true); - trm.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); - verify(notificationManager, never()).sendSimpleEmail(eq(event), anyString(), anyString(), isNull(), any(), anyList()); + finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null); + verify(reservationHelper, never()).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); + } + + @Test + void emailNotSentBecauseReservationNotFinalized() { + when(metadata.isFinalized()).thenReturn(false); + when(sendReservationEmailIfNecessary.getValueAsBooleanOrDefault()).thenReturn(true); + when(sendTickets.getValueAsBooleanOrDefault()).thenReturn(true); + assertThrows(IncompatibleStateException.class, () -> finalizer.sendConfirmationEmailIfNecessary(ticketReservation, List.of(ticket), event, Locale.ENGLISH, null)); + verify(reservationHelper, never()).sendConfirmationEmail(event, ticketReservation, Locale.ENGLISH, null); } } } \ No newline at end of file diff --git a/src/test/java/alfio/manager/TicketReservationManagerUnitTest.java b/src/test/java/alfio/manager/TicketReservationManagerUnitTest.java index c559bed1cb..521890ec90 100644 --- a/src/test/java/alfio/manager/TicketReservationManagerUnitTest.java +++ b/src/test/java/alfio/manager/TicketReservationManagerUnitTest.java @@ -17,6 +17,9 @@ package alfio.manager; import alfio.manager.i18n.MessageSourceManager; +import alfio.manager.support.reservation.OrderSummaryGenerator; +import alfio.manager.support.reservation.ReservationCostCalculator; +import alfio.manager.support.reservation.ReservationEmailContentHelper; import alfio.manager.system.ConfigurationManager; import alfio.manager.user.UserManager; import alfio.model.*; @@ -27,10 +30,11 @@ import alfio.test.util.TestUtil; import alfio.util.Json; import alfio.util.TemplateManager; -import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.transaction.PlatformTransactionManager; @@ -80,6 +84,7 @@ class TicketReservationManagerUnitTest { private ExtensionManager extensionManager; private GroupManager groupManager; private Json json; + private ReservationCostCalculator reservationCostCalculator; @BeforeEach public void setUp() { @@ -125,7 +130,7 @@ public void setUp() { when(messageSourceManager.getRootMessageSource()).thenReturn(messageSource); var purchaseContextManager = mock(PurchaseContextManager.class); when(purchaseContextManager.findByReservationId(anyString())).thenReturn(Optional.of(event)); - + reservationCostCalculator = mock(ReservationCostCalculator.class); manager = new TicketReservationManager(eventRepository, organizationRepository, ticketRepository, @@ -158,148 +163,106 @@ public void setUp() { TestUtil.clockProvider(), purchaseContextManager, mock(SubscriptionRepository.class), - mock(UserManager.class)); - - } - - @Test - void calcReservationCostOnlyTickets() { - when(event.isVatIncluded()).thenReturn(true, false); - when(event.getVat()).thenReturn(BigDecimal.TEN); - when(eventRepository.findByReservationId(eq(TICKET_RESERVATION_ID))).thenReturn(event); - when(ticketReservationRepository.findReservationById(eq(TICKET_RESERVATION_ID))).thenReturn(reservation); - when(reservation.getId()).thenReturn(TICKET_RESERVATION_ID); - when(ticket.getSrcPriceCts()).thenReturn(10); - when(ticketRepository.findTicketsInReservation(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.singletonList(ticket)); - AdditionalServiceItemRepository additionalServiceItemRepository = mock(AdditionalServiceItemRepository.class); - when(additionalServiceItemRepository.findByReservationUuid(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.emptyList()); - - when(event.getVatStatus()).thenReturn(PriceContainer.VatStatus.INCLUDED); - Pair> priceAndDiscount = manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); - TotalPrice included = priceAndDiscount.getLeft(); - Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); - Assertions.assertEquals(10, included.getPriceWithVAT()); - Assertions.assertEquals(1, included.getVAT()); - - when(event.getVatStatus()).thenReturn(PriceContainer.VatStatus.NOT_INCLUDED); - Pair> priceAndDiscountNotIncluded = manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); - TotalPrice notIncluded = priceAndDiscountNotIncluded.getLeft(); - Assertions.assertTrue(priceAndDiscountNotIncluded.getRight().isEmpty()); - Assertions.assertEquals(11, notIncluded.getPriceWithVAT()); - Assertions.assertEquals(1, notIncluded.getVAT()); - } - - @Test - void calcReservationCostWithASVatIncludedInherited() { - initReservationWithAdditionalServices(true, AdditionalService.VatType.INHERITED, 10, 10); - //first: event price vat included, additional service VAT inherited - Pair> priceAndDiscount = manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); - TotalPrice first = priceAndDiscount.getLeft(); - Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); - Assertions.assertEquals(20, first.getPriceWithVAT()); - Assertions.assertEquals(2, first.getVAT()); - } - - @Test - void calcReservationCostWithASVatIncludedASNoVat() { - initReservationWithAdditionalServices(true, AdditionalService.VatType.NONE, 10, 10); - //second: event price vat included, additional service VAT n/a - Pair> priceAndDiscount = manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); - TotalPrice second = priceAndDiscount.getLeft(); - Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); - Assertions.assertEquals(20, second.getPriceWithVAT()); - Assertions.assertEquals(1, second.getVAT()); - } - - @Test - void calcReservationCostWithASVatNotIncludedASInherited() { - initReservationWithAdditionalServices(false, AdditionalService.VatType.INHERITED, 10, 10); - //third: event price vat not included, additional service VAT inherited - Pair> priceAndDiscount = manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); - TotalPrice third = priceAndDiscount.getLeft(); - Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); - Assertions.assertEquals(22, third.getPriceWithVAT()); - Assertions.assertEquals(2, third.getVAT()); - } - - @Test - void calcReservationCostWithASVatNotIncludedASNone() { - initReservationWithAdditionalServices(false, AdditionalService.VatType.NONE, 10, 10); - //fourth: event price vat not included, additional service VAT n/a - Pair> priceAndDiscount = manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); - TotalPrice fourth = priceAndDiscount.getLeft(); - Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); - Assertions.assertEquals(21, fourth.getPriceWithVAT()); - Assertions.assertEquals(1, fourth.getVAT()); - } - - @Test - void testExtractSummaryVatNotIncluded() { - initReservationWithTicket(1000, false); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(1100, 100, 0, 0, "CHF")); - Assertions.assertEquals(1, summaryRows.size()); - Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); - } - - @Test - void testExtractSummaryVatIncluded() { - initReservationWithTicket(1000, true); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); - Assertions.assertEquals(1, summaryRows.size()); - Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); + mock(UserManager.class), + mock(ApplicationEventPublisher.class), + reservationCostCalculator, + mock(ReservationEmailContentHelper.class), + mock(ReservationFinalizer.class), + mock(OrderSummaryGenerator.class)); } @Test - void testExtractSummaryVatIncludedExempt() { - initReservationWithTicket(1000, true); - when(ticket.getVatStatus()).thenReturn(PriceContainer.VatStatus.INCLUDED_EXEMPT); - when(ticket.getFinalPriceCts()).thenReturn(909); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, PriceContainer.VatStatus.INCLUDED_EXEMPT, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); - Assertions.assertEquals(1, summaryRows.size()); - Assertions.assertEquals("9.09", summaryRows.get(0).getPrice()); + void totalReservationCostByID() { + manager.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + verify(reservationCostCalculator).totalReservationCostWithVAT(TICKET_RESERVATION_ID); } @Test - void testExtractSummaryVatNotIncludedExempt() { - initReservationWithTicket(1000, false); - when(ticket.getVatStatus()).thenReturn(PriceContainer.VatStatus.NOT_INCLUDED_EXEMPT); - when(ticket.getFinalPriceCts()).thenReturn(1000); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, PriceContainer.VatStatus.NOT_INCLUDED_EXEMPT, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); - Assertions.assertEquals(1, summaryRows.size()); - Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); + void totalReservationCost() { + manager.totalReservationCostWithVAT(reservation); + verify(reservationCostCalculator).totalReservationCostWithVAT(reservation); } - @Test - void testExtractSummaryVatNotIncludedASInherited() { - initReservationWithAdditionalServices(false, AdditionalService.VatType.INHERITED, 1000, 1000); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(2200, 200, 0, 0, "CHF")); - Assertions.assertEquals(2, summaryRows.size()); - summaryRows.forEach(r -> Assertions.assertEquals("10.00", r.getPrice(), String.format("%s failed", r.getType()))); - } - - @Test - void testExtractSummaryVatIncludedASInherited() { - initReservationWithAdditionalServices(true, AdditionalService.VatType.INHERITED, 1000, 1000); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(2000, 182, 0, 0, "CHF")); - Assertions.assertEquals(2, summaryRows.size()); - summaryRows.forEach(r -> Assertions.assertEquals("10.00", r.getPrice(), String.format("%s failed", r.getType()))); - } - - @Test - void testExtractSummaryVatNotIncludedASNone() { - initReservationWithAdditionalServices(false, AdditionalService.VatType.NONE, 1000, 1000); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); - Assertions.assertEquals(2, summaryRows.size()); - summaryRows.forEach(r -> Assertions.assertEquals("10.00", r.getPrice(), String.format("%s failed", r.getType()))); - } - - @Test - void testExtractSummaryVatIncludedASNone() { - initReservationWithAdditionalServices(true, AdditionalService.VatType.NONE, 1000, 1000); - List summaryRows = manager.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(2000, 100, 0, 0, "CHF")); - Assertions.assertEquals(2, summaryRows.size()); - Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); - Assertions.assertEquals("10.00", summaryRows.get(1).getPrice()); + @Nested + class GenerateSummary { + + private OrderSummaryGenerator generator; + + @BeforeEach + void setUp() { + var subscriptionRepository = mock(SubscriptionRepository.class); + generator = new OrderSummaryGenerator(ticketReservationRepository, auditingRepository, paymentManager, ticketCategoryRepository, additionalServiceTextRepository, subscriptionRepository, ticketRepository, messageSourceManager, + new ReservationCostCalculator(ticketReservationRepository, mock(PurchaseContextManager.class), promoCodeDiscountRepository, subscriptionRepository, ticketRepository, additionalServiceRepository, additionalServiceItemRepository)); + } + + @Test + void testExtractSummaryVatNotIncluded() { + initReservationWithTicket(1000, false); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(1100, 100, 0, 0, "CHF")); + Assertions.assertEquals(1, summaryRows.size()); + Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); + } + + @Test + void testExtractSummaryVatIncluded() { + initReservationWithTicket(1000, true); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); + Assertions.assertEquals(1, summaryRows.size()); + Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); + } + + @Test + void testExtractSummaryVatIncludedExempt() { + initReservationWithTicket(1000, true); + when(ticket.getVatStatus()).thenReturn(PriceContainer.VatStatus.INCLUDED_EXEMPT); + when(ticket.getFinalPriceCts()).thenReturn(909); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, PriceContainer.VatStatus.INCLUDED_EXEMPT, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); + Assertions.assertEquals(1, summaryRows.size()); + Assertions.assertEquals("9.09", summaryRows.get(0).getPrice()); + } + + @Test + void testExtractSummaryVatNotIncludedExempt() { + initReservationWithTicket(1000, false); + when(ticket.getVatStatus()).thenReturn(PriceContainer.VatStatus.NOT_INCLUDED_EXEMPT); + when(ticket.getFinalPriceCts()).thenReturn(1000); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, PriceContainer.VatStatus.NOT_INCLUDED_EXEMPT, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); + Assertions.assertEquals(1, summaryRows.size()); + Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); + } + + @Test + void testExtractSummaryVatNotIncludedASInherited() { + initReservationWithAdditionalServices(false, AdditionalService.VatType.INHERITED, 1000, 1000); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(2200, 200, 0, 0, "CHF")); + Assertions.assertEquals(2, summaryRows.size()); + summaryRows.forEach(r -> Assertions.assertEquals("10.00", r.getPrice(), String.format("%s failed", r.getType()))); + } + + @Test + void testExtractSummaryVatIncludedASInherited() { + initReservationWithAdditionalServices(true, AdditionalService.VatType.INHERITED, 1000, 1000); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(2000, 182, 0, 0, "CHF")); + Assertions.assertEquals(2, summaryRows.size()); + summaryRows.forEach(r -> Assertions.assertEquals("10.00", r.getPrice(), String.format("%s failed", r.getType()))); + } + + @Test + void testExtractSummaryVatNotIncludedASNone() { + initReservationWithAdditionalServices(false, AdditionalService.VatType.NONE, 1000, 1000); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(1000, 100, 0, 0, "CHF")); + Assertions.assertEquals(2, summaryRows.size()); + summaryRows.forEach(r -> Assertions.assertEquals("10.00", r.getPrice(), String.format("%s failed", r.getType()))); + } + + @Test + void testExtractSummaryVatIncludedASNone() { + initReservationWithAdditionalServices(true, AdditionalService.VatType.NONE, 1000, 1000); + List summaryRows = generator.extractSummary(TICKET_RESERVATION_ID, null, event, Locale.ENGLISH, null, new TotalPrice(2000, 100, 0, 0, "CHF")); + Assertions.assertEquals(2, summaryRows.size()); + Assertions.assertEquals("10.00", summaryRows.get(0).getPrice()); + Assertions.assertEquals("10.00", summaryRows.get(1).getPrice()); + } } private void initReservationWithTicket(int ticketPaidPrice, boolean eventVatIncluded) { diff --git a/src/test/java/alfio/manager/UploadedResourceIntegrationTest.java b/src/test/java/alfio/manager/UploadedResourceIntegrationTest.java index 32571db5e3..a160288d71 100644 --- a/src/test/java/alfio/manager/UploadedResourceIntegrationTest.java +++ b/src/test/java/alfio/manager/UploadedResourceIntegrationTest.java @@ -29,6 +29,7 @@ import alfio.repository.EventRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -51,11 +52,10 @@ import static alfio.test.util.IntegrationTestUtil.initEvent; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class UploadedResourceIntegrationTest extends BaseIntegrationTest { +class UploadedResourceIntegrationTest extends BaseIntegrationTest { private static final byte[] FILE = {1,2,3,4}; private static final byte[] ONE_PIXEL_BLACK_GIF = Base64.getDecoder().decode("R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="); diff --git a/src/test/java/alfio/manager/WaitingQueueManagerIntegrationTest.java b/src/test/java/alfio/manager/WaitingQueueManagerIntegrationTest.java index 47a83ae82c..e348294df5 100644 --- a/src/test/java/alfio/manager/WaitingQueueManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/WaitingQueueManagerIntegrationTest.java @@ -36,6 +36,7 @@ import alfio.repository.TicketRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import alfio.util.MonetaryUtil; @@ -60,11 +61,10 @@ import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, WebSecurityConfig.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class WaitingQueueManagerIntegrationTest extends BaseIntegrationTest { +class WaitingQueueManagerIntegrationTest extends BaseIntegrationTest { private static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); diff --git a/src/test/java/alfio/manager/WaitingQueueProcessorIntegrationTest.java b/src/test/java/alfio/manager/WaitingQueueProcessorIntegrationTest.java index 88cd2714b8..2acd55d9c8 100644 --- a/src/test/java/alfio/manager/WaitingQueueProcessorIntegrationTest.java +++ b/src/test/java/alfio/manager/WaitingQueueProcessorIntegrationTest.java @@ -35,6 +35,7 @@ import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -61,11 +62,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class WaitingQueueProcessorIntegrationTest extends BaseIntegrationTest { +class WaitingQueueProcessorIntegrationTest extends BaseIntegrationTest { private static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); diff --git a/src/test/java/alfio/manager/WaitingQueueProcessorMultiThreadedIntegrationTest.java b/src/test/java/alfio/manager/WaitingQueueProcessorMultiThreadedIntegrationTest.java index c242604fae..ace59a0146 100644 --- a/src/test/java/alfio/manager/WaitingQueueProcessorMultiThreadedIntegrationTest.java +++ b/src/test/java/alfio/manager/WaitingQueueProcessorMultiThreadedIntegrationTest.java @@ -36,6 +36,7 @@ import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.ClockProvider; import org.apache.commons.lang3.StringUtils; @@ -59,10 +60,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -public class WaitingQueueProcessorMultiThreadedIntegrationTest { +class WaitingQueueProcessorMultiThreadedIntegrationTest { private static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); diff --git a/src/test/java/alfio/manager/i18n/MessageSourceManagerIntegrationTest.java b/src/test/java/alfio/manager/i18n/MessageSourceManagerIntegrationTest.java index 6ac5a57f92..3864fedde6 100644 --- a/src/test/java/alfio/manager/i18n/MessageSourceManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/i18n/MessageSourceManagerIntegrationTest.java @@ -30,6 +30,7 @@ import alfio.repository.EventRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.test.util.IntegrationTestUtil; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; @@ -53,11 +54,10 @@ import static alfio.test.util.IntegrationTestUtil.initEvent; import static org.junit.jupiter.api.Assertions.assertEquals; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class MessageSourceManagerIntegrationTest extends BaseIntegrationTest { +class MessageSourceManagerIntegrationTest extends BaseIntegrationTest { @Autowired diff --git a/src/test/java/alfio/manager/support/reservation/ReservationCostCalculatorTest.java b/src/test/java/alfio/manager/support/reservation/ReservationCostCalculatorTest.java new file mode 100644 index 0000000000..0d7a86c25e --- /dev/null +++ b/src/test/java/alfio/manager/support/reservation/ReservationCostCalculatorTest.java @@ -0,0 +1,193 @@ +/** + * 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.manager.support.reservation; + +import alfio.manager.PurchaseContextManager; +import alfio.model.*; +import alfio.repository.*; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ReservationCostCalculatorTest { + + private static final String TICKET_RESERVATION_ID = "abcdef"; + private Event event; + private Ticket ticket; + private TicketCategory ticketCategory; + private TicketReservation reservation; + private EventRepository eventRepository; + private TicketReservationRepository ticketReservationRepository; + private TicketRepository ticketRepository; + private TicketCategoryRepository ticketCategoryRepository; + private AdditionalServiceItemRepository additionalServiceItemRepository; + private AdditionalServiceRepository additionalServiceRepository; + private AdditionalServiceTextRepository additionalServiceTextRepository; + private ReservationCostCalculator calculator; + + @BeforeEach + void setUp() { + ticketRepository = mock(TicketRepository.class); + ticketReservationRepository = mock(TicketReservationRepository.class); + eventRepository = mock(EventRepository.class); + reservation = mock(TicketReservation.class); + event = mock(Event.class); + when(event.getCurrency()).thenReturn("CHF"); + when(event.event()).thenReturn(Optional.of(event)); + ticket = mock(Ticket.class); + when(ticket.getCurrencyCode()).thenReturn("CHF"); + when(ticket.getCategoryId()).thenReturn(1); + ticketCategory = mock(TicketCategory.class); + when(ticketCategory.getId()).thenReturn(1); + ticketCategoryRepository = mock(TicketCategoryRepository.class); + additionalServiceRepository = mock(AdditionalServiceRepository.class); + additionalServiceItemRepository = mock(AdditionalServiceItemRepository.class); + additionalServiceTextRepository = mock(AdditionalServiceTextRepository.class); + var purchaseContextManager = mock(PurchaseContextManager.class); + when(purchaseContextManager.findByReservationId(anyString())).thenReturn(Optional.of(event)); + calculator = new ReservationCostCalculator( + ticketReservationRepository, + purchaseContextManager, + mock(PromoCodeDiscountRepository.class), + mock(SubscriptionRepository.class), + ticketRepository, + additionalServiceRepository, + additionalServiceItemRepository + ); + } + + @Test + void calcReservationCostOnlyTickets() { + when(event.isVatIncluded()).thenReturn(true, false); + when(event.getVat()).thenReturn(BigDecimal.TEN); + when(eventRepository.findByReservationId(eq(TICKET_RESERVATION_ID))).thenReturn(event); + when(ticketReservationRepository.findReservationById(eq(TICKET_RESERVATION_ID))).thenReturn(reservation); + when(reservation.getId()).thenReturn(TICKET_RESERVATION_ID); + when(ticket.getSrcPriceCts()).thenReturn(10); + when(ticketRepository.findTicketsInReservation(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.singletonList(ticket)); + AdditionalServiceItemRepository additionalServiceItemRepository = mock(AdditionalServiceItemRepository.class); + when(additionalServiceItemRepository.findByReservationUuid(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.emptyList()); + + when(event.getVatStatus()).thenReturn(PriceContainer.VatStatus.INCLUDED); + Pair> priceAndDiscount = calculator.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + TotalPrice included = priceAndDiscount.getLeft(); + Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); + Assertions.assertEquals(10, included.getPriceWithVAT()); + Assertions.assertEquals(1, included.getVAT()); + + when(event.getVatStatus()).thenReturn(PriceContainer.VatStatus.NOT_INCLUDED); + Pair> priceAndDiscountNotIncluded = calculator.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + TotalPrice notIncluded = priceAndDiscountNotIncluded.getLeft(); + Assertions.assertTrue(priceAndDiscountNotIncluded.getRight().isEmpty()); + Assertions.assertEquals(11, notIncluded.getPriceWithVAT()); + Assertions.assertEquals(1, notIncluded.getVAT()); + } + + @Test + void calcReservationCostWithASVatIncludedInherited() { + initReservationWithAdditionalServices(true, AdditionalService.VatType.INHERITED, 10, 10); + //first: event price vat included, additional service VAT inherited + Pair> priceAndDiscount = calculator.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + TotalPrice first = priceAndDiscount.getLeft(); + Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); + Assertions.assertEquals(20, first.getPriceWithVAT()); + Assertions.assertEquals(2, first.getVAT()); + } + + @Test + void calcReservationCostWithASVatIncludedASNoVat() { + initReservationWithAdditionalServices(true, AdditionalService.VatType.NONE, 10, 10); + //second: event price vat included, additional service VAT n/a + Pair> priceAndDiscount = calculator.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + TotalPrice second = priceAndDiscount.getLeft(); + Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); + Assertions.assertEquals(20, second.getPriceWithVAT()); + Assertions.assertEquals(1, second.getVAT()); + } + + @Test + void calcReservationCostWithASVatNotIncludedASInherited() { + initReservationWithAdditionalServices(false, AdditionalService.VatType.INHERITED, 10, 10); + //third: event price vat not included, additional service VAT inherited + Pair> priceAndDiscount = calculator.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + TotalPrice third = priceAndDiscount.getLeft(); + Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); + Assertions.assertEquals(22, third.getPriceWithVAT()); + Assertions.assertEquals(2, third.getVAT()); + } + + @Test + void calcReservationCostWithASVatNotIncludedASNone() { + initReservationWithAdditionalServices(false, AdditionalService.VatType.NONE, 10, 10); + //fourth: event price vat not included, additional service VAT n/a + Pair> priceAndDiscount = calculator.totalReservationCostWithVAT(TICKET_RESERVATION_ID); + TotalPrice fourth = priceAndDiscount.getLeft(); + Assertions.assertTrue(priceAndDiscount.getRight().isEmpty()); + Assertions.assertEquals(21, fourth.getPriceWithVAT()); + Assertions.assertEquals(1, fourth.getVAT()); + } + + private void initReservationWithTicket(int ticketPaidPrice, boolean eventVatIncluded) { + when(event.isVatIncluded()).thenReturn(eventVatIncluded); + when(event.getVatStatus()).thenReturn(eventVatIncluded ? PriceContainer.VatStatus.INCLUDED : PriceContainer.VatStatus.NOT_INCLUDED); + when(event.getVat()).thenReturn(BigDecimal.TEN); + when(event.getId()).thenReturn(1); + when(eventRepository.findByReservationId(eq(TICKET_RESERVATION_ID))).thenReturn(event); + when(ticketReservationRepository.findReservationById(eq(TICKET_RESERVATION_ID))).thenReturn(reservation); + when(ticket.getSrcPriceCts()).thenReturn(ticketPaidPrice); + when(ticket.getCategoryId()).thenReturn(1); + when(ticketRepository.findTicketsInReservation(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.singletonList(ticket)); + when(ticketCategoryRepository.getByIdAndActive(eq(1), eq(1))).thenReturn(ticketCategory); + when(ticketCategoryRepository.getByIdsAndActive(anyCollection(), eq(1))).thenReturn(List.of(ticketCategory)); + when(reservation.getId()).thenReturn(TICKET_RESERVATION_ID); + } + + private void initReservationWithAdditionalServices(boolean eventVatIncluded, AdditionalService.VatType additionalServiceVatType, int ticketSrcPrice, int asSrcPrice) { + + initReservationWithTicket(ticketSrcPrice, eventVatIncluded); + + AdditionalServiceItem additionalServiceItem = mock(AdditionalServiceItem.class); + when(additionalServiceItem.getCurrencyCode()).thenReturn("CHF"); + AdditionalService additionalService = mock(AdditionalService.class); + when(additionalService.getCurrencyCode()).thenReturn("CHF"); + when(additionalService.getId()).thenReturn(1); + + when(additionalServiceItemRepository.findByReservationUuid(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.singletonList(additionalServiceItem)); + when(additionalServiceItem.getAdditionalServiceId()).thenReturn(1); + when(additionalServiceRepository.loadAllForEvent(eq(1))).thenReturn(List.of(additionalService)); + when(additionalServiceRepository.getById(eq(1), eq(1))).thenReturn(additionalService); + when(additionalServiceItem.getSrcPriceCts()).thenReturn(asSrcPrice); + when(additionalService.getVatType()).thenReturn(additionalServiceVatType); + AdditionalServiceItemRepository additionalServiceItemRepository = mock(AdditionalServiceItemRepository.class); + when(additionalServiceItemRepository.findByReservationUuid(eq(TICKET_RESERVATION_ID))).thenReturn(Collections.emptyList()); + AdditionalServiceText text = mock(AdditionalServiceText.class); + when(text.getId()).thenReturn(1); + when(text.getLocale()).thenReturn("en"); + when(additionalServiceTextRepository.findBestMatchByLocaleAndType(anyInt(), eq("en"), eq(AdditionalServiceText.TextType.TITLE))).thenReturn(text); + } + +} \ No newline at end of file diff --git a/src/test/java/alfio/manager/system/AdminJobManagerInvoker.java b/src/test/java/alfio/manager/system/AdminJobManagerInvoker.java index b0984156e0..24094531bb 100644 --- a/src/test/java/alfio/manager/system/AdminJobManagerInvoker.java +++ b/src/test/java/alfio/manager/system/AdminJobManagerInvoker.java @@ -28,4 +28,8 @@ public AdminJobManagerInvoker(AdminJobManager adminJobManager) { public void invokeProcessPendingExtensionRetry(ZonedDateTime timestamp) { adminJobManager.processPendingExtensionRetry(timestamp); } + + public void invokeProcessPendingReservationsRetry(ZonedDateTime timestamp) { + adminJobManager.processPendingReservationsRetry(timestamp); + } } diff --git a/src/test/java/alfio/manager/system/DataMigratorIntegrationTest.java b/src/test/java/alfio/manager/system/DataMigratorIntegrationTest.java index c00f12f54e..2924623157 100644 --- a/src/test/java/alfio/manager/system/DataMigratorIntegrationTest.java +++ b/src/test/java/alfio/manager/system/DataMigratorIntegrationTest.java @@ -37,6 +37,7 @@ import alfio.repository.TicketReservationRepository; import alfio.repository.system.EventMigrationRepository; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.time.DateUtils; @@ -57,10 +58,10 @@ import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -public class DataMigratorIntegrationTest extends BaseIntegrationTest { +class DataMigratorIntegrationTest extends BaseIntegrationTest { private static final int AVAILABLE_SEATS = 20; private static final Map DESCRIPTION = Collections.singletonMap("en", "desc"); diff --git a/src/test/java/alfio/repository/EventRepositoryIntegrationTest.java b/src/test/java/alfio/repository/EventRepositoryIntegrationTest.java index c09e81f66f..da7579a439 100644 --- a/src/test/java/alfio/repository/EventRepositoryIntegrationTest.java +++ b/src/test/java/alfio/repository/EventRepositoryIntegrationTest.java @@ -30,6 +30,7 @@ import alfio.model.modification.TicketCategoryModification; import alfio.model.result.Result; import alfio.repository.user.OrganizationRepository; +import alfio.test.util.AlfioIntegrationTest; import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import ch.digitalfondue.npjt.AffectedRowCountAndKey; @@ -57,11 +58,10 @@ import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest +@AlfioIntegrationTest @ContextConfiguration(classes = {DataSourceConfiguration.class, WebSecurityConfig.class, TestConfiguration.class}) @ActiveProfiles({Initializer.PROFILE_DEV, Initializer.PROFILE_DISABLE_JOBS, Initializer.PROFILE_INTEGRATION_TEST}) -@Transactional -public class EventRepositoryIntegrationTest extends BaseIntegrationTest { +class EventRepositoryIntegrationTest extends BaseIntegrationTest { private static final String NEW_YORK_TZ = "America/New_York"; private static final String ORG_NAME = "name"; diff --git a/src/test/java/alfio/test/util/AlfioIntegrationTest.java b/src/test/java/alfio/test/util/AlfioIntegrationTest.java new file mode 100644 index 0000000000..548d30fe50 --- /dev/null +++ b/src/test/java/alfio/test/util/AlfioIntegrationTest.java @@ -0,0 +1,30 @@ +/** + * 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.test.util; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; + +import java.lang.annotation.*; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@SpringBootTest +@ExtendWith(DataCleaner.class) +public @interface AlfioIntegrationTest { +} diff --git a/src/test/java/alfio/test/util/DataCleaner.java b/src/test/java/alfio/test/util/DataCleaner.java new file mode 100644 index 0000000000..160be17ea2 --- /dev/null +++ b/src/test/java/alfio/test/util/DataCleaner.java @@ -0,0 +1,93 @@ +/** + * 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.test.util; + +import alfio.config.authentication.support.APITokenAuthentication; +import alfio.manager.OrganizationDeleter; +import alfio.util.RefreshableDataSource; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.UncategorizedSQLException; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static alfio.config.authentication.support.AuthenticationConstants.SYSTEM_API_CLIENT; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DataCleaner implements AfterEachCallback, BeforeEachCallback { + + private static final Logger log = LoggerFactory.getLogger(DataCleaner.class); + private final Set initialConfiguration = new HashSet<>(); + + @Override + public void afterEach(ExtensionContext context) { + if (hasDataSource(context)) { + // test succeeded + var applicationContext = SpringExtension.getApplicationContext(context); + var jdbc = applicationContext.getBean(NamedParameterJdbcTemplate.class); + try { + // delete configuration + assertTrue(jdbc.update("delete from configuration where c_key not in (:keys)", Map.of("keys", initialConfiguration)) >= 0); + assertTrue(jdbc.update("delete from configuration_organization", Map.of()) >= 0); + assertTrue(jdbc.update("delete from configuration_event", Map.of()) >= 0); + assertTrue(jdbc.update("delete from extension_event", Map.of()) >= 0); + assertTrue(jdbc.update("delete from extension_configuration_metadata_value", Map.of()) >= 0); + assertTrue(jdbc.update("delete from extension_configuration_metadata", Map.of()) >= 0); + assertTrue(jdbc.update("delete from extension_log", Map.of()) >= 0); + assertTrue(jdbc.update("delete from extension_support", Map.of()) >= 0); + assertTrue(jdbc.update("delete from admin_job_queue", Map.of()) >= 0); + // delete organization + var organizationDeleter = applicationContext.getBean(OrganizationDeleter.class); + jdbc.queryForList("select id from organization", Map.of(), Integer.class) + .forEach(orgId -> organizationDeleter.deleteOrganization(orgId, new APITokenAuthentication("TEST", "", List.of(new SimpleGrantedAuthority("ROLE_" + SYSTEM_API_CLIENT))))); + assertTrue(jdbc.queryForList("select id from organization", Map.of(), Integer.class).isEmpty()); + jdbc.update("delete from user_profile", Map.of()); + jdbc.update("delete from ba_user", Map.of()); + } catch (UncategorizedSQLException e) { + log.warn("cannot delete data. Connection was already aborted?", e); + } + } + getDataSource(context).refresh(); + } + + @Override + public void beforeEach(ExtensionContext context) { + if (hasDataSource(context)) { + var jdbc = SpringExtension.getApplicationContext(context).getBean(NamedParameterJdbcTemplate.class); + initialConfiguration.addAll(jdbc.queryForList("select c_key from configuration", Map.of(), String.class)); + } + } + + private boolean hasDataSource(ExtensionContext extensionContext) { + var applicationContext = SpringExtension.getApplicationContext(extensionContext); + return applicationContext.getBeanNamesForType(RefreshableDataSource.class).length > 0 + && applicationContext.getBeanNamesForType(NamedParameterJdbcTemplate.class).length > 0; + } + + private RefreshableDataSource getDataSource(ExtensionContext extensionContext) { + return SpringExtension.getApplicationContext(extensionContext).getBean(RefreshableDataSource.class); + } +} diff --git a/src/test/java/alfio/util/BaseIntegrationTest.java b/src/test/java/alfio/util/BaseIntegrationTest.java index dc22ce818b..7f07d187ab 100644 --- a/src/test/java/alfio/util/BaseIntegrationTest.java +++ b/src/test/java/alfio/util/BaseIntegrationTest.java @@ -16,19 +16,34 @@ */ package alfio.util; +import alfio.BaseTestConfiguration; +import alfio.config.authentication.support.APITokenAuthentication; +import alfio.manager.OrganizationDeleter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.UncategorizedSQLException; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; -import java.util.Base64; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import static alfio.config.authentication.support.AuthenticationConstants.SYSTEM_API_CLIENT; import static org.junit.jupiter.api.Assertions.*; @ActiveProfiles(resolver = ActiveTravisProfileResolver.class) -public class BaseIntegrationTest { +public abstract class BaseIntegrationTest { + public static final byte[] ONE_PIXEL_BLACK_GIF = Base64.getDecoder().decode("R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="); public static void testTransferEventToAnotherOrg(int eventId, diff --git a/src/test/java/alfio/util/RefreshableDataSource.java b/src/test/java/alfio/util/RefreshableDataSource.java new file mode 100644 index 0000000000..a9a42ae503 --- /dev/null +++ b/src/test/java/alfio/util/RefreshableDataSource.java @@ -0,0 +1,45 @@ +/** + * 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.util; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.jdbc.datasource.DelegatingDataSource; + +import javax.sql.DataSource; +import java.util.concurrent.atomic.AtomicReference; + +public class RefreshableDataSource extends DelegatingDataSource { + + private final HikariConfig config; + private final AtomicReference dataSource = new AtomicReference<>(); + + public RefreshableDataSource(HikariConfig config) { + this.config = config; + this.dataSource.set(new HikariDataSource(config)); + } + + @Override + public DataSource getTargetDataSource() { + return dataSource.get(); + } + + public void refresh() { + this.dataSource.getAndSet(new HikariDataSource(config)).close(); + } + +} diff --git a/src/test/resources/retry-reservation/fail-invoice-number-generator.js b/src/test/resources/retry-reservation/fail-invoice-number-generator.js new file mode 100644 index 0000000000..658ee86969 --- /dev/null +++ b/src/test/resources/retry-reservation/fail-invoice-number-generator.js @@ -0,0 +1,23 @@ +/** + * The script metadata object describes whether or not your extension should be invoked asynchronously, and which events it supports + * @returns {{ async: boolean, events: string[] }} + */ +function getScriptMetadata() { + return { + id: 'myExtensionIdentifier', + displayName: 'My Extension', + version: 0, // optional + async: false, + events: [ + 'INVOICE_GENERATION' + ] + }; +} +/** + * Executes the extension. + * @param scriptEvent + * @returns Object + */ +function executeScript(scriptEvent) { + throw 'error during process'; +} diff --git a/src/test/resources/retry-reservation/fail-ticket-metadata.js b/src/test/resources/retry-reservation/fail-ticket-metadata.js new file mode 100644 index 0000000000..cce26a1117 --- /dev/null +++ b/src/test/resources/retry-reservation/fail-ticket-metadata.js @@ -0,0 +1,23 @@ +/** + * The script metadata object describes whether or not your extension should be invoked asynchronously, and which events it supports + * @returns {{ async: boolean, events: string[] }} + */ +function getScriptMetadata() { + return { + id: 'myExtensionIdentifier', + displayName: 'My Extension', + version: 0, // optional + async: false, + events: [ + 'TICKET_ASSIGNED_GENERATE_METADATA' + ] + }; +} +/** + * Executes the extension. + * @param scriptEvent + * @returns Object + */ +function executeScript(scriptEvent) { + throw 'error during process'; +} \ No newline at end of file diff --git a/src/test/resources/retry-reservation/success-invoice-number-generator.js b/src/test/resources/retry-reservation/success-invoice-number-generator.js new file mode 100644 index 0000000000..f65abe631e --- /dev/null +++ b/src/test/resources/retry-reservation/success-invoice-number-generator.js @@ -0,0 +1,25 @@ +/** + * The script metadata object describes whether or not your extension should be invoked asynchronously, and which events it supports + * @returns {{ async: boolean, events: string[] }} + */ +function getScriptMetadata() { + return { + id: 'myExtensionIdentifier', + displayName: 'My Extension', + version: 0, // optional + async: false, + events: [ + 'INVOICE_GENERATION' + ] + }; +} +/** + * Executes the extension. + * @param scriptEvent + * @returns Object + */ +function executeScript(scriptEvent) { + return { + invoiceNumber: 'ABCD' + }; +} diff --git a/src/test/resources/retry-reservation/success-ticket-metadata.js b/src/test/resources/retry-reservation/success-ticket-metadata.js new file mode 100644 index 0000000000..539bd4da57 --- /dev/null +++ b/src/test/resources/retry-reservation/success-ticket-metadata.js @@ -0,0 +1,27 @@ +/** + * The script metadata object describes whether or not your extension should be invoked asynchronously, and which events it supports + * @returns {{ async: boolean, events: string[] }} + */ +function getScriptMetadata() { + return { + id: 'myExtensionIdentifier', + displayName: 'My Extension', + version: 0, // optional + async: false, + events: [ + 'TICKET_ASSIGNED_GENERATE_METADATA' + ] + }; +} +/** + * Executes the extension. + * @param scriptEvent + * @returns Object + */ +function executeScript(scriptEvent) { + return { + attributes: { + uuid: ticket.uuid + } + }; +} \ No newline at end of file