From 93ed61b0df46384a07bd7534fc1ba0a7b8d8c467 Mon Sep 17 00:00:00 2001 From: Celestino Bellone Date: Mon, 29 Nov 2021 20:08:59 +0100 Subject: [PATCH 1/4] assign ticket automatically to subscription owners for compatible events --- .../alfio/config/DataSourceConfiguration.java | 23 +- .../v2/user/ReservationApiV2Controller.java | 92 ++------ .../controller/form/ReservationCodeForm.java | 8 +- src/main/java/alfio/job/Jobs.java | 10 + .../AssignTicketToSubscriberJobExecutor.java | 152 ++++++++++++ .../manager/AdminReservationManager.java | 62 ++++- .../AdminReservationRequestManager.java | 51 +++- .../alfio/manager/PurchaseContextManager.java | 7 + .../manager/TicketReservationManager.java | 74 +++++- .../manager/system/AdminJobExecutor.java | 3 +- .../AdminReservationModification.java | 29 ++- .../AvailableSubscriptionsByEvent.java | 68 ++++++ .../alfio/model/system/ConfigurationKeys.java | 7 +- .../AdminReservationRequestRepository.java | 3 + .../repository/SubscriptionRepository.java | 14 +- .../alfio/repository/TicketRepository.java | 2 +- ...4_2.0.0.41__CREATE_INDEX_SUBSCRIPTIONS.sql | 24 ++ .../db/PGSQL/afterMigrate__000_VIEW_drops.sql | 3 +- ..._VIEW_available_subscriptions_by_event.sql | 51 ++++ .../admin/partials/configuration/system.html | 11 +- .../js/admin/directive/admin-directive.js | 8 + .../reservation/BaseReservationFlowTest.java | 2 +- ...onFlowWithSubscriptionIntegrationTest.java | 51 +--- ...oSubscriberJobExecutorIntegrationTest.java | 218 ++++++++++++++++++ ...dminReservationManagerIntegrationTest.java | 8 +- .../alfio/test/util/IntegrationTestUtil.java | 71 +++++- 26 files changed, 878 insertions(+), 174 deletions(-) create mode 100644 src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java create mode 100644 src/main/java/alfio/model/subscription/AvailableSubscriptionsByEvent.java create mode 100644 src/main/resources/alfio/db/PGSQL/V204_2.0.0.41__CREATE_INDEX_SUBSCRIPTIONS.sql create mode 100644 src/main/resources/alfio/db/PGSQL/afterMigrate__015_VIEW_available_subscriptions_by_event.sql create mode 100644 src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java diff --git a/src/main/java/alfio/config/DataSourceConfiguration.java b/src/main/java/alfio/config/DataSourceConfiguration.java index 980bc29f81..456d6fddfd 100644 --- a/src/main/java/alfio/config/DataSourceConfiguration.java +++ b/src/main/java/alfio/config/DataSourceConfiguration.java @@ -21,6 +21,7 @@ import alfio.config.support.JSONColumnMapper; import alfio.config.support.PlatformProvider; import alfio.job.Jobs; +import alfio.job.executor.AssignTicketToSubscriberJobExecutor; import alfio.job.executor.BillingDocumentJobExecutor; import alfio.job.executor.ReservationJobExecutor; import alfio.manager.*; @@ -29,6 +30,8 @@ import alfio.manager.system.ConfigurationManager; import alfio.repository.EventDeleterRepository; import alfio.repository.EventRepository; +import alfio.repository.SubscriptionRepository; +import alfio.repository.TicketCategoryRepository; import alfio.repository.system.AdminJobQueueRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.OrganizationRepository; @@ -245,9 +248,10 @@ AdminJobManager adminJobManager(AdminJobQueueRepository adminJobQueueRepository, PlatformTransactionManager transactionManager, ClockProvider clockProvider, ReservationJobExecutor reservationJobExecutor, - BillingDocumentJobExecutor billingDocumentJobExecutor) { + BillingDocumentJobExecutor billingDocumentJobExecutor, + AssignTicketToSubscriberJobExecutor assignTicketToSubscriberJobExecutor) { return new AdminJobManager( - List.of(reservationJobExecutor, billingDocumentJobExecutor), + List.of(reservationJobExecutor, billingDocumentJobExecutor, assignTicketToSubscriberJobExecutor), adminJobQueueRepository, transactionManager, clockProvider); @@ -267,6 +271,21 @@ BillingDocumentJobExecutor billingDocumentJobExecutor(BillingDocumentManager bil return new BillingDocumentJobExecutor(billingDocumentManager, ticketReservationManager, eventRepository, notificationManager, organizationRepository); } + @Bean + AssignTicketToSubscriberJobExecutor assignTicketToSubscriberJobExecutor(AdminReservationRequestManager requestManager, + ConfigurationManager configurationManager, + SubscriptionRepository subscriptionRepository, + EventRepository eventRepository, + ClockProvider clockProvider, + TicketCategoryRepository ticketCategoryRepository) { + return new AssignTicketToSubscriberJobExecutor(requestManager, + configurationManager, + subscriptionRepository, + eventRepository, + clockProvider, + ticketCategoryRepository); + } + @Bean @Profile(Initializer.PROFILE_DEMO) DemoModeDataManager demoModeDataManager(UserRepository userRepository, 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 6cd5f977e4..cd4195acc5 100644 --- a/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java +++ b/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java @@ -36,10 +36,8 @@ import alfio.manager.support.PaymentResult; import alfio.manager.support.response.ValidatedResponse; import alfio.manager.system.ConfigurationManager; -import alfio.manager.system.ReservationPriceCalculator; import alfio.manager.user.PublicUserManager; import alfio.model.*; -import alfio.model.TicketCategory.TicketAccessType; import alfio.model.PurchaseContext.PurchaseContextType; import alfio.model.subscription.Subscription; import alfio.model.subscription.SubscriptionUsageExceeded; @@ -58,36 +56,27 @@ import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.validation.BindingResult; import org.springframework.validation.Errors; -import org.springframework.validation.ValidationUtils; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.math.BigDecimal; import java.security.Principal; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Predicate; import java.util.stream.Collectors; -import static alfio.model.PriceContainer.VatStatus.*; -import static alfio.model.system.ConfigurationKeys.*; -import static alfio.util.MonetaryUtil.unitToCents; -import static java.util.stream.Collectors.groupingBy; +import static alfio.model.system.ConfigurationKeys.ENABLE_ITALY_E_INVOICING; +import static alfio.model.system.ConfigurationKeys.FORCE_TICKET_OWNER_ASSIGNMENT_AT_RESERVATION; import static java.util.stream.Collectors.toMap; -import static org.apache.commons.lang3.StringUtils.trimToNull; @RestController @AllArgsConstructor @@ -620,7 +609,7 @@ public ResponseEntity initTransaction(@PathVaria return ResponseEntity.badRequest().build(); } - Optional> responseEntity = getEventReservationPair(reservationId) + Optional> responseEntity = purchaseContextManager.getReservationWithPurchaseContext(reservationId) .map(pair -> { var event = pair.getLeft(); return ticketReservationManager.initTransaction(event, reservationId, paymentMethod, allParams) @@ -637,7 +626,7 @@ public ResponseEntity initTransaction(@PathVaria public ResponseEntity removeToken(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId) { - var res = getEventReservationPair(reservationId).map(et -> paymentManager.removePaymentTokenReservation(et.getRight().getId())).orElse(false); + var res = purchaseContextManager.getReservationWithPurchaseContext(reservationId).map(et -> paymentManager.removePaymentTokenReservation(et.getRight().getId())).orElse(false); return ResponseEntity.ok(res); } @@ -647,18 +636,10 @@ public ResponseEntity removeToken(@PathVariable("eventName") String eve }) public ResponseEntity deletePaymentAttempt(@PathVariable("reservationId") String reservationId) { - var res = getEventReservationPair(reservationId).map(et -> ticketReservationManager.cancelPendingPayment(et.getRight().getId(), et.getLeft())).orElse(false); + var res = purchaseContextManager.getReservationWithPurchaseContext(reservationId).map(et -> ticketReservationManager.cancelPendingPayment(et.getRight().getId(), et.getLeft())).orElse(false); return ResponseEntity.ok(res); } - //FIXME: rename ->getPurchaseContextReservationPair - private Optional> getEventReservationPair(String reservationId) { - return purchaseContextManager.findByReservationId(reservationId) - .map(event -> Pair.of(event, ticketReservationManager.findById(reservationId))) - .filter(pair -> pair.getRight().isPresent()) - .map(pair -> Pair.of(pair.getLeft(), pair.getRight().orElseThrow())); - } - @GetMapping({ "/reservation/{reservationId}/payment/{method}/status", "/event/{eventName}/reservation/{reservationId}/payment/{method}/status" //<-deprecated @@ -673,7 +654,7 @@ public ResponseEntity getTransactionStatus( return ResponseEntity.badRequest().build(); } - return getEventReservationPair(reservationId) + return purchaseContextManager.getReservationWithPurchaseContext(reservationId) .flatMap(pair -> paymentManager.getTransactionStatus(pair.getRight(), paymentMethod)) .map(pr -> ResponseEntity.ok(new ReservationPaymentResult(pr.isSuccessful(), pr.isRedirect(), pr.getRedirectUrl(), pr.isFailed(), pr.getGatewayIdOrNull()))) .orElseGet(() -> ResponseEntity.notFound().build()); @@ -684,57 +665,14 @@ public ResponseEntity> applyCode(@PathVariable("reser if(reservationCodeForm.getType() != ReservationCodeForm.ReservationCodeType.SUBSCRIPTION) { throw new IllegalStateException(reservationCodeForm.getType() + " not supported"); } - boolean res = getEventReservationPair(reservationId).map(et -> { - boolean isUUID = reservationCodeForm.isCodeUUID(); - log.trace("is code UUID {}", isUUID); - var pin = reservationCodeForm.getCode(); - if (!isUUID && !PinGenerator.isPinValid(pin, Subscription.PIN_LENGTH)) { - bindingResult.reject("error.restrictedValue"); - return false; - } - - //ensure pin length, as we will do a like concat(pin,'%'), it could be dangerous to have an empty string... - Assert.isTrue(pin.length() >= Subscription.PIN_LENGTH, "Pin must have a length of at least 8 characters"); - - var partialUuid = !isUUID ? PinGenerator.pinToPartialUuid(pin, Subscription.PIN_LENGTH) : pin; - var email = reservationCodeForm.getEmail(); - var requireEmail = false; - int count; - if (isUUID) { - count = subscriptionRepository.countSubscriptionById(UUID.fromString(pin)); - } else { - count = subscriptionRepository.countSubscriptionByPartialUuid(partialUuid); - if (count > 1) { - count = subscriptionRepository.countSubscriptionByPartialUuidAndEmail(partialUuid, email); - requireEmail = true; - } - } - log.trace("code count is {}", count); - if (count == 0) { - bindingResult.reject(isUUID ? "subscription.uuid.not.found" : "subscription.pin.not.found"); - } - if (count > 1) { - bindingResult.reject("subscription.code.insert.full"); - } - - if (bindingResult.hasErrors()) { - return false; - } - - var subscriptionId = isUUID ? UUID.fromString(pin) : requireEmail ? subscriptionRepository.getSubscriptionIdByPartialUuidAndEmail(partialUuid, email) : subscriptionRepository.getSubscriptionIdByPartialUuid(partialUuid); - var subscriptionDescriptor = subscriptionRepository.findDescriptorBySubscriptionId(subscriptionId); - var subscription = subscriptionRepository.findSubscriptionById(subscriptionId); - subscription.isValid(Optional.of(bindingResult)); - if (bindingResult.hasErrors()) { - return false; - } - try { - return ticketReservationManager.applySubscriptionCode(((Event)et.getLeft()).getId(), et.getRight(), subscriptionDescriptor, subscriptionId); - } catch (SubscriptionUsageExceeded | SubscriptionUsageExceededForEvent ex) { - bindingResult.reject(ex instanceof SubscriptionUsageExceeded ? "subscription.max-usage-reached" : "subscription.max-usage-reached-per-event"); - return false; - } - }).orElse(false); + boolean res = purchaseContextManager.getReservationWithPurchaseContext(reservationId) + .map(et -> ticketReservationManager.validateAndApplySubscriptionCode(et.getLeft(), + et.getRight(), + reservationCodeForm.getCodeAsUUID(), + reservationCodeForm.getCode(), + reservationCodeForm.getEmail(), + bindingResult)) + .orElse(false); return ResponseEntity.ok(ValidatedResponse.toResponse(bindingResult, res)); } @@ -742,7 +680,7 @@ public ResponseEntity> applyCode(@PathVariable("reser public ResponseEntity removeCode(@PathVariable("reservationId") String reservationId, @RequestParam("type") ReservationCodeForm.ReservationCodeType type) { boolean res = false; if (type == ReservationCodeForm.ReservationCodeType.SUBSCRIPTION) { - res = getEventReservationPair(reservationId).map(et -> ticketReservationManager.removeSubscription(et.getRight())).orElse(false); + res = purchaseContextManager.getReservationWithPurchaseContext(reservationId).map(et -> ticketReservationManager.removeSubscription(et.getRight())).orElse(false); } return ResponseEntity.ok(res); } diff --git a/src/main/java/alfio/controller/form/ReservationCodeForm.java b/src/main/java/alfio/controller/form/ReservationCodeForm.java index b11096c3fd..9f5d95f208 100644 --- a/src/main/java/alfio/controller/form/ReservationCodeForm.java +++ b/src/main/java/alfio/controller/form/ReservationCodeForm.java @@ -19,6 +19,7 @@ import lombok.Data; import java.io.Serializable; +import java.util.Optional; import java.util.UUID; @Data @@ -34,12 +35,11 @@ public enum ReservationCodeType { SUBSCRIPTION } - public boolean isCodeUUID() { + public Optional getCodeAsUUID() { try { - UUID.fromString(code); - return true; + return Optional.of(UUID.fromString(code)); } catch (IllegalArgumentException e) { - return false; + return Optional.empty(); } } } diff --git a/src/main/java/alfio/job/Jobs.java b/src/main/java/alfio/job/Jobs.java index 2367e4948d..5513c31acd 100644 --- a/src/main/java/alfio/job/Jobs.java +++ b/src/main/java/alfio/job/Jobs.java @@ -95,6 +95,16 @@ public void sendOfflinePaymentReminderToEventOrganizers() { } } + @Scheduled(cron = "#{environment.acceptsProfiles('dev') ? '0 * * * * *' : '0 0 0/1 * * ?'}") + public void assignTicketsToSubscribers() { + log.trace("running job assignTicketsToSubscribers"); + try { + adminJobManager.scheduleExecution(AdminJobExecutor.JobName.ASSIGN_TICKETS_TO_SUBSCRIBERS, Map.of()); + } finally { + log.trace("end job sendOfflinePaymentReminderToEventOrganizers"); + } + } + @Scheduled(fixedRate = FIVE_SECONDS) public void sendEmails() { diff --git a/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java b/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java new file mode 100644 index 0000000000..22bc4bfbd7 --- /dev/null +++ b/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java @@ -0,0 +1,152 @@ +/** + * 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.AdminReservationRequestManager; +import alfio.manager.system.AdminJobExecutor; +import alfio.manager.system.ConfigurationManager; +import alfio.model.Event; +import alfio.model.TicketCategory; +import alfio.model.modification.AdminReservationModification; +import alfio.model.modification.AdminReservationModification.Attendee; +import alfio.model.modification.AdminReservationModification.Category; +import alfio.model.modification.AdminReservationModification.CustomerData; +import alfio.model.modification.AdminReservationModification.TicketsInfo; +import alfio.model.modification.DateTimeModification; +import alfio.model.subscription.AvailableSubscriptionsByEvent; +import alfio.model.system.AdminJobSchedule; +import alfio.repository.EventRepository; +import alfio.repository.SubscriptionRepository; +import alfio.repository.TicketCategoryRepository; +import alfio.util.ClockProvider; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static alfio.model.system.ConfigurationKeys.GENERATE_TICKETS_FOR_SUBSCRIPTIONS; + +@Component +public class AssignTicketToSubscriberJobExecutor implements AdminJobExecutor { + + private final AdminReservationRequestManager requestManager; + private final ConfigurationManager configurationManager; + private final SubscriptionRepository subscriptionRepository; + private final EventRepository eventRepository; + private final ClockProvider clockProvider; + private final TicketCategoryRepository ticketCategoryRepository; + + public AssignTicketToSubscriberJobExecutor(AdminReservationRequestManager requestManager, + ConfigurationManager configurationManager, + SubscriptionRepository subscriptionRepository, + EventRepository eventRepository, + ClockProvider clockProvider, + TicketCategoryRepository ticketCategoryRepository) { + this.requestManager = requestManager; + this.configurationManager = configurationManager; + this.subscriptionRepository = subscriptionRepository; + this.eventRepository = eventRepository; + this.clockProvider = clockProvider; + this.ticketCategoryRepository = ticketCategoryRepository; + } + + @Override + public Set getJobNames() { + return EnumSet.of(JobName.ASSIGN_TICKETS_TO_SUBSCRIBERS); + } + + @Override + public String process(AdminJobSchedule schedule) { + // 1. Find all subscriptions bought until now. Filters: + // - subscription_descriptor_fk is linked to the current event + // - id does not have any reservations attached to the current event + // - validity_from is <= now() + // - validity_to is null or > now() + // - status = 'ACQUIRED' + var subscriptionsByEvent = subscriptionRepository.loadAvailableSubscriptionsByEvent() + .stream() + .collect(Collectors.groupingBy(AvailableSubscriptionsByEvent::getEventId)); + if (!subscriptionsByEvent.isEmpty()) { + eventRepository.findByIds(subscriptionsByEvent.keySet()).forEach(event -> { + // 2. for each event check if the flag is active + boolean generationEnabled = configurationManager.getFor(GENERATE_TICKETS_FOR_SUBSCRIPTIONS, event.getConfigurationLevel()) + .getValueAsBooleanOrDefault(); + if (generationEnabled) { + var subscriptions = subscriptionsByEvent.get(event.getId()); + var categories = ticketCategoryRepository.findAllTicketCategories(event.getId()); + if (!categories.isEmpty()) { + var category = categories.get(0); + // 3. create reservation import request for the subscribers. ID is "AUTO_${eventShortName}_${now_ISO}" + var requestId = String.format("AUTO_%s_%s", event.getShortName(), LocalDateTime.now(clockProvider.getClock()).format(DateTimeFormatter.ISO_DATE_TIME)); + requestManager.insertRequest(requestId, + buildBody(event, subscriptions, category), + event, + false, + "admin"); + + } + } + }); + } + return null; + } + + private AdminReservationModification buildBody(Event event, + List subscriptions, + TicketCategory category) { + var clock = clockProvider.getClock(); + return new AdminReservationModification( + new DateTimeModification(LocalDate.now(clock), LocalTime.now(clock).plus(5L, ChronoUnit.MINUTES)), + new CustomerData("", "", "", null, "", null, null, null, null), + List.of(new TicketsInfo( + new Category(category.getId(), category.getName(), category.getPrice(), category.getTicketAccessType()), + toAttendees(subscriptions), + false, + false + )), + event.getContentLanguages().get(0).getLanguage(), + false, + false, + null, + null, + null, + null + ); + } + + private List toAttendees(List subscriptions) { + return subscriptions.stream() + .map(s -> new Attendee(null, + s.getFirstName(), + s.getLastName(), + s.getEmailAddress(), + s.getUserLanguage(), + false, + s.getSubscriptionId() + "_auto", + s.getSubscriptionId(), + Map.of()) + ).collect(Collectors.toList()); + } +} diff --git a/src/main/java/alfio/manager/AdminReservationManager.java b/src/main/java/alfio/manager/AdminReservationManager.java index 0f19caab37..04903f95b2 100644 --- a/src/main/java/alfio/manager/AdminReservationManager.java +++ b/src/main/java/alfio/manager/AdminReservationManager.java @@ -49,6 +49,7 @@ import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.stereotype.Component; @@ -58,6 +59,7 @@ import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.Assert; +import org.springframework.validation.MapBindingResult; import java.math.BigDecimal; import java.util.*; @@ -117,7 +119,12 @@ public class AdminReservationManager { private final SubscriptionRepository subscriptionRepository; //the following methods have an explicit transaction handling, therefore the @Transactional annotation is not helpful here - public Result, PurchaseContext>> confirmReservation(PurchaseContextType purchaseContextType, String eventName, String reservationId, String username, Notification notification) { + Result, PurchaseContext>> confirmReservation(PurchaseContextType purchaseContextType, + String eventName, + String reservationId, + String username, + Notification notification, + UUID subscriptionId) { DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); TransactionTemplate template = new TransactionTemplate(transactionManager, definition); return template.execute(status -> { @@ -125,7 +132,7 @@ public Result, PurchaseContext>> confirmR Result, PurchaseContext>> result = 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)) + .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()) { @@ -140,6 +147,13 @@ public Result, PurchaseContext>> confirmR } }); } + public Result, PurchaseContext>> confirmReservation(PurchaseContextType purchaseContextType, + String eventName, + String reservationId, + String username, + Notification notification) { + return confirmReservation(purchaseContextType, eventName, reservationId, username, notification, null); + } public Result updateReservation(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, AdminReservationModification adminReservationModification, String username) { DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); @@ -328,23 +342,47 @@ private Result, PurchaseContext>> perform PurchaseContext purchaseContext, TicketReservation original, Notification notification, - String username) { + String username, + UUID subscriptionId) { try { + + var reservation = original; + + if (subscriptionId != null && purchaseContext.ofType(PurchaseContextType.event)) { + var subscriptionDetails = subscriptionRepository.findSubscriptionById(subscriptionId); + var bindingResult = new MapBindingResult(new HashMap<>(), ""); + boolean result = ticketReservationManager.validateAndApplySubscriptionCode(purchaseContext, + original, + Optional.of(subscriptionId), + subscriptionId.toString(), + subscriptionDetails.getEmail(), + bindingResult); + if (!result) { + var message = bindingResult.getGlobalErrors().stream() + .findFirst() + .map(DefaultMessageSourceResolvable::getCode) + .orElse("Unknown error"); + return Result.error(ErrorCode.custom(message, String.format("Cannot assign subscription %s to Reservation %s", subscriptionId, reservationId))); + } + + reservation = ticketReservationManager.findById(reservationId).orElseThrow(); + } + PaymentSpecification spec = new PaymentSpecification(reservationId, null, - original.getFinalPriceCts(), + reservation.getFinalPriceCts(), purchaseContext, - original.getEmail(), - new CustomerName(original.getFullName(), original.getFirstName(), original.getLastName(), purchaseContext.mustUseFirstAndLastName()), - original.getBillingAddress(), - original.getCustomerReference(), - LocaleUtil.forLanguageTag(original.getUserLanguage()), + reservation.getEmail(), + new CustomerName(reservation.getFullName(), reservation.getFirstName(), reservation.getLastName(), purchaseContext.mustUseFirstAndLastName()), + reservation.getBillingAddress(), + reservation.getCustomerReference(), + LocaleUtil.forLanguageTag(reservation.getUserLanguage()), false, false, null, - original.getVatCountryCode(), - original.getVatNr(), - original.getVatStatus(), + reservation.getVatCountryCode(), + reservation.getVatNr(), + reservation.getVatStatus(), false, false); diff --git a/src/main/java/alfio/manager/AdminReservationRequestManager.java b/src/main/java/alfio/manager/AdminReservationRequestManager.java index cea002154c..865c502ac0 100644 --- a/src/main/java/alfio/manager/AdminReservationRequestManager.java +++ b/src/main/java/alfio/manager/AdminReservationRequestManager.java @@ -31,6 +31,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; @@ -74,7 +75,7 @@ public Result scheduleReservations(String eventName, boolean singleReservation, String username) { - //safety check: if there are more than 150 people in a single reservation, the reservation page could take a while before showing up. + //safety check: if there are more than 150 people in a single reservation, the reservation page could take a while before showing up, //therefore we will limit the maximum amount of people in a single reservation to 100. This will be addressed in rel. 2.0 if(singleReservation && body.getTicketsInfo().stream().mapToLong(ti -> ti.getAttendees().size()).sum() > 100) { @@ -135,8 +136,14 @@ private Result, Event>> processReservatio try { String eventName = event.getShortName(); String username = user.getUsername(); - Result, Event>> result = adminReservationManager.createReservation(request.getBody(), eventName, username) - .flatMap(r -> adminReservationManager.confirmReservation(PurchaseContext.PurchaseContextType.event, eventName, r.getLeft().getId(), username, orEmpty(request.getBody().getNotification()))) + var requestBody = request.getBody(); + Result, Event>> result = adminReservationManager.createReservation(requestBody, eventName, username) + .flatMap(r -> adminReservationManager.confirmReservation(PurchaseContext.PurchaseContextType.event, + eventName, + r.getLeft().getId(), + username, + orEmpty(requestBody.getNotification()), + requestBody.getLinkedSubscriptionId())) .map(triple -> Triple.of(triple.getLeft(), triple.getMiddle(), (Event) triple.getRight())); if(!result.isSuccess()) { status.rollbackToSavepoint(savepoint); @@ -159,10 +166,35 @@ private MapSqlParameterSource buildParameterSource(Long id, Result insertRequest(AdminReservationModification body, EventAndOrganizationId event, boolean singleReservation, String username) { try { - String requestId = UUID.randomUUID().toString(); + return insertRequest(UUID.randomUUID().toString(), body, event, singleReservation, username); + } catch(DataIntegrityViolationException e) { + log.error("data integrity violation while inserting request", e); + return Result.error(ErrorCode.custom("internal_server_error", e.getMessage())); + } + } + + /** + * Insert a request using the provided ID and body + * + * @param requestId requestId, must be unique + * @param body request body which can be split across multiple reservations + * @param event the event for which we need to create reservations + * @param singleReservation whether the request will produce one or more reservations + * @param username user requesting the import + * @return {@code Result} the operation result + * @throws DataIntegrityViolationException if the given ID already exists + */ + public Result insertRequest(String requestId, + AdminReservationModification body, + EventAndOrganizationId event, + boolean singleReservation, + String username) { + try { long userId = userRepository.findIdByUserName(username).orElseThrow(IllegalArgumentException::new); adminReservationRequestRepository.insertRequest(requestId, userId, event, spread(body, singleReservation)); return Result.success(requestId); + } catch (DataIntegrityViolationException e) { + throw e; } catch (Exception e) { log.error("can't insert reservation request", e); return Result.error(ErrorCode.custom("internal_server_error", e.getMessage())); @@ -181,7 +213,16 @@ private Stream spread(AdminReservationModification String language = StringUtils.defaultIfBlank(attendee.getLanguage(), src.getLanguage()); CustomerData cd = new CustomerData(attendee.getFirstName(), attendee.getLastName(), attendee.getEmailAddress(), null, language, null, null, null, null); - return new AdminReservationModification(src.getExpiration(), cd, singletonList(p.getRight()), language, src.isUpdateContactData(), false, null, src.getNotification(), null); + return new AdminReservationModification(src.getExpiration(), + cd, + singletonList(p.getRight()), + language, + src.isUpdateContactData(), + false, + null, + src.getNotification(), + null, + attendee.getSubscriptionId()); }); } diff --git a/src/main/java/alfio/manager/PurchaseContextManager.java b/src/main/java/alfio/manager/PurchaseContextManager.java index e39a4be5e8..e57f16639c 100644 --- a/src/main/java/alfio/manager/PurchaseContextManager.java +++ b/src/main/java/alfio/manager/PurchaseContextManager.java @@ -18,11 +18,13 @@ import alfio.manager.system.ConfigurationLevel; import alfio.model.PurchaseContext; +import alfio.model.TicketReservation; import alfio.repository.EventRepository; import alfio.repository.SubscriptionRepository; import alfio.repository.TicketReservationRepository; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -86,4 +88,9 @@ public Optional detectConfigurationLevel(String eventShortNa return Optional.empty(); } } + + public Optional> getReservationWithPurchaseContext(String reservationId) { + return findByReservationId(reservationId) + .map(event -> Pair.of(event, ticketReservationRepository.findReservationById(reservationId))); + } } diff --git a/src/main/java/alfio/manager/TicketReservationManager.java b/src/main/java/alfio/manager/TicketReservationManager.java index 89cb94930a..8dd8b68d1e 100644 --- a/src/main/java/alfio/manager/TicketReservationManager.java +++ b/src/main/java/alfio/manager/TicketReservationManager.java @@ -17,6 +17,7 @@ package alfio.manager; import alfio.controller.api.support.TicketHelper; +import alfio.controller.form.ReservationCodeForm; import alfio.controller.form.UpdateTicketOwnerForm; import alfio.controller.support.TemplateProcessor; import alfio.manager.PaymentManager.PaymentMethodDTO.PaymentMethodStatus; @@ -85,7 +86,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.Assert; import org.springframework.validation.BindingResult; +import org.springframework.validation.Errors; import org.springframework.web.util.UriComponentsBuilder; import java.math.BigDecimal; @@ -2887,10 +2890,67 @@ private boolean automaticConfirmOfflinePayment(Event event, String reservationId } } - public boolean applySubscriptionCode(int eventId, - TicketReservation reservation, - SubscriptionDescriptor subscriptionDescriptor, - UUID subscriptionId) throws SubscriptionUsageExceeded, SubscriptionUsageExceededForEvent { + public boolean validateAndApplySubscriptionCode(PurchaseContext purchaseContext, + TicketReservation reservation, + Optional subscriptionUUID, + String pin, + String email, + BindingResult bindingResult) { + + Assert.isTrue(purchaseContext.ofType(PurchaseContextType.event), "PurchaseContext must be of type \"event\""); + boolean isUUID = subscriptionUUID.isPresent(); + log.trace("is code UUID {}", isUUID); + if (!isUUID && !PinGenerator.isPinValid(pin, Subscription.PIN_LENGTH)) { + bindingResult.reject("error.restrictedValue"); + return false; + } + + //ensure pin length, as we will do a like concat(pin,'%'), it could be dangerous to have an empty string... + Assert.isTrue(pin.length() >= Subscription.PIN_LENGTH, "Pin must have a length of at least 8 characters"); + + var partialUuid = !isUUID ? PinGenerator.pinToPartialUuid(pin, Subscription.PIN_LENGTH) : pin; + var requireEmail = false; + int count; + if (isUUID) { + count = subscriptionRepository.countSubscriptionById(subscriptionUUID.get()); + } else { + count = subscriptionRepository.countSubscriptionByPartialUuid(partialUuid); + if (count > 1) { + count = subscriptionRepository.countSubscriptionByPartialUuidAndEmail(partialUuid, email); + requireEmail = true; + } + } + log.trace("code count is {}", count); + if (count == 0) { + bindingResult.reject(isUUID ? "subscription.uuid.not.found" : "subscription.pin.not.found"); + } + if (count > 1) { + bindingResult.reject("subscription.code.insert.full"); + } + + if (bindingResult.hasErrors()) { + return false; + } + + var subscriptionId = isUUID ? UUID.fromString(pin) : requireEmail ? subscriptionRepository.getSubscriptionIdByPartialUuidAndEmail(partialUuid, email) : subscriptionRepository.getSubscriptionIdByPartialUuid(partialUuid); + var subscriptionDescriptor = subscriptionRepository.findDescriptorBySubscriptionId(subscriptionId); + var subscription = subscriptionRepository.findSubscriptionById(subscriptionId); + subscription.isValid(Optional.of(bindingResult)); + if (bindingResult.hasErrors()) { + return false; + } + try { + return applySubscriptionCode(((Event)purchaseContext).getId(), reservation, subscriptionDescriptor, subscriptionId); + } catch (SubscriptionUsageExceeded | SubscriptionUsageExceededForEvent ex) { + bindingResult.reject(ex instanceof SubscriptionUsageExceeded ? "subscription.max-usage-reached" : "subscription.max-usage-reached-per-event"); + return false; + } + } + + boolean applySubscriptionCode(int eventId, + TicketReservation reservation, + SubscriptionDescriptor subscriptionDescriptor, + UUID subscriptionId) throws SubscriptionUsageExceeded, SubscriptionUsageExceededForEvent { log.trace("entering applySubscription {}", subscriptionId); @@ -2903,6 +2963,12 @@ public boolean applySubscriptionCode(int eventId, if (!subscription.isValid()) { return false; } + + if (!subscriptionRepository.isSubscriptionLinkedToEvent(eventId, subscription.getSubscriptionDescriptorId(), subscription.getOrganizationId())) { + log.warn("Attempt to use subscription {} - descriptor {} for event {}", subscription.getId(), subscriptionDescriptor.getId(), eventId); + return false; + } + try { log.trace("applying subscription {} to reservation {}", subscriptionId, reservation.getId()); // find out how many tickets can be included in the subscription diff --git a/src/main/java/alfio/manager/system/AdminJobExecutor.java b/src/main/java/alfio/manager/system/AdminJobExecutor.java index 68d4f452cf..1756380be4 100644 --- a/src/main/java/alfio/manager/system/AdminJobExecutor.java +++ b/src/main/java/alfio/manager/system/AdminJobExecutor.java @@ -29,7 +29,8 @@ enum JobName { SEND_OFFLINE_PAYMENT_REMINDER, UNKNOWN, SEND_OFFLINE_PAYMENT_TO_ORGANIZER, - REGENERATE_INVOICES; + REGENERATE_INVOICES, + ASSIGN_TICKETS_TO_SUBSCRIBERS; public static JobName safeValueOf(String value) { return Arrays.stream(values()) diff --git a/src/main/java/alfio/model/modification/AdminReservationModification.java b/src/main/java/alfio/model/modification/AdminReservationModification.java index 60f34b229b..f4e43af783 100644 --- a/src/main/java/alfio/model/modification/AdminReservationModification.java +++ b/src/main/java/alfio/model/modification/AdminReservationModification.java @@ -45,6 +45,7 @@ public class AdminReservationModification implements Serializable { private final AdvancedBillingOptions advancedBillingOptions; private final Notification notification; private final SubscriptionDetails subscriptionDetails; + private final UUID linkedSubscriptionId; @JsonCreator public AdminReservationModification(@JsonProperty("expiration") DateTimeModification expiration, @@ -55,7 +56,8 @@ public AdminReservationModification(@JsonProperty("expiration") DateTimeModifica @JsonProperty("updateAdvancedBillingOptions") Boolean updateAdvancedBillingOptions, @JsonProperty("advancedBillingOptions") AdvancedBillingOptions advancedBillingOptions, @JsonProperty("notification") Notification notification, - @JsonProperty("subscriptionDetails") SubscriptionDetails subscriptionDetails) { + @JsonProperty("subscriptionDetails") SubscriptionDetails subscriptionDetails, + @JsonProperty("linkedSubscriptionId") UUID linkedSubscriptionId) { this.expiration = expiration; this.customerData = customerData; this.ticketsInfo = ticketsInfo; @@ -65,6 +67,7 @@ public AdminReservationModification(@JsonProperty("expiration") DateTimeModifica this.advancedBillingOptions = advancedBillingOptions; this.notification = notification; this.subscriptionDetails = subscriptionDetails; + this.linkedSubscriptionId = linkedSubscriptionId; } @Getter @@ -166,6 +169,7 @@ public static class Attendee { private final String language; private final boolean reassignmentForbidden; private final String reference; + private final UUID subscriptionId; private final Map> additionalInfo; @JsonCreator @@ -176,6 +180,7 @@ public Attendee(@JsonProperty("ticketId") Integer ticketId, @JsonProperty("language") String language, @JsonProperty("forbidReassignment") Boolean reassignmentForbidden, @JsonProperty("reference") String reference, + @JsonProperty("subscriptionId") UUID subscriptionId, @JsonProperty("additionalInfo") Map> additionalInfo) { this.ticketId = ticketId; this.firstName = trimToEmpty(firstName); @@ -184,6 +189,7 @@ public Attendee(@JsonProperty("ticketId") Integer ticketId, this.language = language; this.reassignmentForbidden = Optional.ofNullable(reassignmentForbidden).orElse(false); this.reference = reference; + this.subscriptionId = subscriptionId; this.additionalInfo = Optional.ofNullable(additionalInfo).orElse(Collections.emptyMap()); } @@ -257,10 +263,27 @@ public static String summary(AdminReservationModification src) { List ticketsInfo = src.ticketsInfo.stream().map(ti -> { List attendees = ti.getAttendees() .stream() - .map(a -> new Attendee(a.ticketId, placeholderIfNotEmpty(a.firstName), placeholderIfNotEmpty(a.lastName), placeholderIfNotEmpty(a.emailAddress), a.language, a.reassignmentForbidden, a.reference,singletonMap("hasAdditionalInfo", singletonList(String.valueOf(a.additionalInfo.isEmpty()))))).collect(toList()); + .map(a -> new Attendee(a.ticketId, + placeholderIfNotEmpty(a.firstName), + placeholderIfNotEmpty(a.lastName), + placeholderIfNotEmpty(a.emailAddress), + a.language, + a.reassignmentForbidden, + a.reference, + a.subscriptionId, + singletonMap("hasAdditionalInfo", singletonList(String.valueOf(a.additionalInfo.isEmpty()))))).collect(toList()); return new TicketsInfo(ti.getCategory(), attendees, ti.isAddSeatsIfNotAvailable(), ti.isUpdateAttendees()); }).collect(toList()); - return Json.toJson(new AdminReservationModification(src.expiration, summaryForCustomerData(src.customerData), ticketsInfo, src.getLanguage(), src.updateContactData, src.updateAdvancedBillingOptions, src.advancedBillingOptions, src.notification, src.subscriptionDetails)); + return Json.toJson(new AdminReservationModification(src.expiration, + summaryForCustomerData(src.customerData), + ticketsInfo, + src.getLanguage(), + src.updateContactData, + src.updateAdvancedBillingOptions, + src.advancedBillingOptions, + src.notification, + src.subscriptionDetails, + src.linkedSubscriptionId)); } catch(Exception e) { return e.toString(); } diff --git a/src/main/java/alfio/model/subscription/AvailableSubscriptionsByEvent.java b/src/main/java/alfio/model/subscription/AvailableSubscriptionsByEvent.java new file mode 100644 index 0000000000..fa4a954604 --- /dev/null +++ b/src/main/java/alfio/model/subscription/AvailableSubscriptionsByEvent.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.model.subscription; + +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; + +import java.util.UUID; + +public class AvailableSubscriptionsByEvent { + private final int eventId; + private final UUID subscriptionId; + private final String emailAddress; + private final String firstName; + private final String lastName; + private final String userLanguage; + + public AvailableSubscriptionsByEvent(@Column("event_id") int eventId, + @Column("subscription_id") UUID subscriptionId, + @Column("email_address") String emailAddress, + @Column("first_name") String firstName, + @Column("last_name") String lastName, + @Column("user_language") String userLanguage) { + this.eventId = eventId; + this.subscriptionId = subscriptionId; + this.emailAddress = emailAddress; + this.firstName = firstName; + this.lastName = lastName; + this.userLanguage = userLanguage; + } + + public int getEventId() { + return eventId; + } + + public UUID getSubscriptionId() { + return subscriptionId; + } + + public String getEmailAddress() { + return emailAddress; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + public String getUserLanguage() { + return userLanguage; + } +} diff --git a/src/main/java/alfio/model/system/ConfigurationKeys.java b/src/main/java/alfio/model/system/ConfigurationKeys.java index 0f4fe5c724..f471a12f27 100644 --- a/src/main/java/alfio/model/system/ConfigurationKeys.java +++ b/src/main/java/alfio/model/system/ConfigurationKeys.java @@ -256,7 +256,9 @@ public enum ConfigurationKeys { DESCRIPTION_MAXLENGTH("Max characters in descriptions (default 4000)", false, SettingCategory.GENERAL, ComponentType.TEXT, false, EnumSet.of(SYSTEM)), OPENID_PUBLIC_ENABLED("Enable OpenID for public users (default: false)", false, SettingCategory.OPENID, ComponentType.BOOLEAN, false, EnumSet.of(SYSTEM), "false"), - OPENID_CONFIGURATION_JSON("OpenID configuration", false, SettingCategory.OPENID, ComponentType.TEXTAREA, false, EnumSet.of(SYSTEM)) + OPENID_CONFIGURATION_JSON("OpenID configuration", false, SettingCategory.OPENID, ComponentType.TEXTAREA, false, EnumSet.of(SYSTEM)), + + GENERATE_TICKETS_FOR_SUBSCRIPTIONS("Generate and send tickets automatically to subscription holders for compatible events (default: false)", false, SettingCategory.SUBSCRIPTIONS, ComponentType.BOOLEAN, false, EnumSet.of(SYSTEM, ORGANIZATION), "false") ; @Getter @@ -278,7 +280,8 @@ public enum SettingCategory { PASS_INTEGRATION("Pass Integration"), WAITING_LIST("Waiting List"), IMPORT_ATTENDEE("Import Attendees"), - OPENID("Public users Authentication"); + OPENID("Public users Authentication"), + SUBSCRIPTIONS("Subscriptions"); private final String description; SettingCategory(String description) { diff --git a/src/main/java/alfio/repository/AdminReservationRequestRepository.java b/src/main/java/alfio/repository/AdminReservationRequestRepository.java index 9c955ea6fb..1f0a585c5c 100644 --- a/src/main/java/alfio/repository/AdminReservationRequestRepository.java +++ b/src/main/java/alfio/repository/AdminReservationRequestRepository.java @@ -49,6 +49,9 @@ default void insertRequest(String requestId, long userId, EventAndOrganizationId @Query("select id from admin_reservation_request where status = 'PENDING' order by request_id limit :limit for update skip locked") List findPendingForUpdate(@Bind("limit") int limit); + @Query("select count(*) from admin_reservation_request where status = 'PENDING'") + Integer countPending(); + @Query("select * from admin_reservation_request where id = :id") AdminReservationRequest fetchCompleteById(@Bind("id") long id); diff --git a/src/main/java/alfio/repository/SubscriptionRepository.java b/src/main/java/alfio/repository/SubscriptionRepository.java index 2c7fce0051..f2023efc42 100644 --- a/src/main/java/alfio/repository/SubscriptionRepository.java +++ b/src/main/java/alfio/repository/SubscriptionRepository.java @@ -18,13 +18,10 @@ import alfio.model.AllocationStatus; import alfio.model.PriceContainer.VatStatus; -import alfio.model.subscription.EventSubscriptionLink; -import alfio.model.subscription.Subscription; -import alfio.model.subscription.SubscriptionDescriptor; +import alfio.model.subscription.*; import alfio.model.subscription.SubscriptionDescriptor.SubscriptionTimeUnit; import alfio.model.subscription.SubscriptionDescriptor.SubscriptionUsageType; import alfio.model.subscription.SubscriptionDescriptor.SubscriptionValidityType; -import alfio.model.subscription.SubscriptionDescriptorWithStatistics; import alfio.model.support.Array; import alfio.model.support.JSONData; import alfio.model.transaction.PaymentProxy; @@ -162,6 +159,12 @@ int updateSubscriptionDescriptor(@Bind("title") @JSONData Map ti @Query("select * from subscription_descriptor_statistics where sd_organization_id_fk = :organizationId") List findAllWithStatistics(@Bind("organizationId") int organizationId); + @Query("select exists (select 1 from subscription_event where event_id_fk = :eventId" + + " and subscription_descriptor_id_fk = :subscriptionDescriptorId::uuid and organization_id_fk = :organizationId)") + boolean isSubscriptionLinkedToEvent(@Bind("eventId") int eventId, + @Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, + @Bind("organizationId") int organizationId); + @Query(INSERT_SUBSCRIPTION_LINK) int linkSubscriptionAndEvent(@Bind("subscriptionId") UUID subscriptionId, @Bind("eventId") int eventId, @@ -293,4 +296,7 @@ int assignSubscription(@Bind("reservationId") String reservationId, @Query("update subscription set status = 'CANCELLED' where reservation_id_fk = :reservationId") int cancelSubscriptions(@Bind("reservationId") String reservationId); + + @Query("select * from available_subscriptions_by_event") + List loadAvailableSubscriptionsByEvent(); } diff --git a/src/main/java/alfio/repository/TicketRepository.java b/src/main/java/alfio/repository/TicketRepository.java index f1486b9458..d6e608d141 100644 --- a/src/main/java/alfio/repository/TicketRepository.java +++ b/src/main/java/alfio/repository/TicketRepository.java @@ -43,7 +43,7 @@ public interface TicketRepository { String REVERT_TO_FREE = "update ticket set status = 'FREE' where status = 'RELEASED' and event_id = :eventId"; String SORT_TICKETS = "order by category_id asc, uuid asc"; - String RESET_TICKET = " TICKETS_RESERVATION_ID = null, FULL_NAME = null, EMAIL_ADDRESS = null, SPECIAL_PRICE_ID_FK = null, LOCKED_ASSIGNMENT = false, USER_LANGUAGE = null, REMINDER_SENT = false, SRC_PRICE_CTS = 0, FINAL_PRICE_CTS = 0, VAT_CTS = 0, DISCOUNT_CTS = 0, FIRST_NAME = null, LAST_NAME = null, EXT_REFERENCE = null, TAGS = array[]::text[], VAT_STATUS = null "; + String RESET_TICKET = " TICKETS_RESERVATION_ID = null, FULL_NAME = null, EMAIL_ADDRESS = null, SPECIAL_PRICE_ID_FK = null, LOCKED_ASSIGNMENT = false, USER_LANGUAGE = null, REMINDER_SENT = false, SRC_PRICE_CTS = 0, FINAL_PRICE_CTS = 0, VAT_CTS = 0, DISCOUNT_CTS = 0, FIRST_NAME = null, LAST_NAME = null, EXT_REFERENCE = null, TAGS = array[]::text[], VAT_STATUS = null, METADATA = '{}'::jsonb "; String RELEASE_TICKET_QUERY = "update ticket set status = 'RELEASED', uuid = :newUuid, " + RESET_TICKET + " where id = :ticketId and status in('ACQUIRED', 'PENDING', 'TO_BE_PAID') and tickets_reservation_id = :reservationId and event_id = :eventId"; diff --git a/src/main/resources/alfio/db/PGSQL/V204_2.0.0.41__CREATE_INDEX_SUBSCRIPTIONS.sql b/src/main/resources/alfio/db/PGSQL/V204_2.0.0.41__CREATE_INDEX_SUBSCRIPTIONS.sql new file mode 100644 index 0000000000..83fdd328ae --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V204_2.0.0.41__CREATE_INDEX_SUBSCRIPTIONS.sql @@ -0,0 +1,24 @@ +-- +-- 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 . +-- + +create index idx_subscription_validity_to + on subscription (validity_to) + where (validity_to IS NOT NULL); + +create index idx_subscription_validity_from + on subscription (validity_from) + where (validity_from IS NOT NULL); \ No newline at end of file diff --git a/src/main/resources/alfio/db/PGSQL/afterMigrate__000_VIEW_drops.sql b/src/main/resources/alfio/db/PGSQL/afterMigrate__000_VIEW_drops.sql index 79733304de..42796789de 100644 --- a/src/main/resources/alfio/db/PGSQL/afterMigrate__000_VIEW_drops.sql +++ b/src/main/resources/alfio/db/PGSQL/afterMigrate__000_VIEW_drops.sql @@ -29,4 +29,5 @@ drop view if exists subscription_descriptor_statistics; drop view if exists basic_event_with_optional_subscription; drop view if exists reservation_and_subscription_and_tx; drop view if exists extension_capabilities; -drop view if exists reservation_with_purchase_context; \ No newline at end of file +drop view if exists reservation_with_purchase_context; +drop view if exists available_subscriptions_by_event; \ No newline at end of file diff --git a/src/main/resources/alfio/db/PGSQL/afterMigrate__015_VIEW_available_subscriptions_by_event.sql b/src/main/resources/alfio/db/PGSQL/afterMigrate__015_VIEW_available_subscriptions_by_event.sql new file mode 100644 index 0000000000..b2cf343add --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/afterMigrate__015_VIEW_available_subscriptions_by_event.sql @@ -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 . +-- + +create view available_subscriptions_by_event as ( + with usage_by_subscription_id as ( + select s.id subscription_id, + sum(case when t.subscription_id_fk is not null then 1 else 0 end) usage + from subscription s + left join tickets_reservation t on t.subscription_id_fk = s.id + group by 1 + ), subscription_expiration as ( + select id, + coalesce(s.validity_from, 'yesterday'::timestamp) inception, + coalesce(s.validity_to, 'tomorrow'::timestamp) expiration + from subscription s + ) + select e.id event_id, + s.id as subscription_id, + s.email_address as email_address, + s.first_name as first_name, + s.last_name as last_name, + r.user_language as user_language + from event e + join subscription_event se on se.event_id_fk = e.id + join subscription_descriptor sd on se.subscription_descriptor_id_fk = sd.id + join subscription s on sd.id = s.subscription_descriptor_fk + join usage_by_subscription_id u on s.id = u.subscription_id + join subscription_expiration exp on s.id = exp.id + join tickets_reservation r on r.id = s.reservation_id_fk + where e.end_ts > now() -- make sure that event is not in the past + and s.status = 'ACQUIRED' + and not exists(select id from tickets_reservation tr where tr.subscription_id_fk = s.id and tr.event_id_fk = e.id) + and exp.inception <= now() + and exp.expiration > now() + and (s.max_usage = -1 or s.max_usage > u.usage) + order by e.id +); diff --git a/src/main/webapp/resources/angular-templates/admin/partials/configuration/system.html b/src/main/webapp/resources/angular-templates/admin/partials/configuration/system.html index e6293444a0..bb86554995 100644 --- a/src/main/webapp/resources/angular-templates/admin/partials/configuration/system.html +++ b/src/main/webapp/resources/angular-templates/admin/partials/configuration/system.html @@ -274,7 +274,7 @@

Revolut integration

- + +
+
+ +
+
+ diff --git a/src/main/webapp/resources/js/admin/directive/admin-directive.js b/src/main/webapp/resources/js/admin/directive/admin-directive.js index c551cb047e..2dfb6c4963 100644 --- a/src/main/webapp/resources/js/admin/directive/admin-directive.js +++ b/src/main/webapp/resources/js/admin/directive/admin-directive.js @@ -1217,6 +1217,14 @@ { id: 'PAYMENT', name: 'Payment' + }, + { + id: 'IMPORT_ATTENDEES', + name: 'Import Attendees' + }, + { + id: 'SUBSCRIPTIONS', + name: 'Subscriptions' } ]; }); 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 a16efc05c2..caf2defca8 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 @@ -1205,7 +1205,7 @@ private TicketWithCategory testEncryptedCheckInPayload(Principal principal, protected void testAddSubscription(ReservationFlowContext context, int numberOfTickets) { var form = new ReservationForm(); var ticketReservation = new TicketReservationModification(); - ticketReservation.setAmount(numberOfTickets); + ticketReservation.setQuantity(numberOfTickets); var categoriesResponse = eventApiV2Controller.getTicketCategories(context.event.getShortName(), null); assertTrue(categoriesResponse.getStatusCode().is2xxSuccessful()); assertNotNull(categoriesResponse.getBody()); 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 b32d40b18f..41320d1601 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 @@ -34,20 +34,16 @@ import alfio.extension.ExtensionService; import alfio.manager.*; import alfio.manager.user.UserManager; -import alfio.model.AllocationStatus; import alfio.model.Event; -import alfio.model.PriceContainer; import alfio.model.TicketCategory; import alfio.model.metadata.AlfioMetadata; import alfio.model.modification.DateTimeModification; -import alfio.model.modification.SubscriptionDescriptorModification; import alfio.model.modification.TicketCategoryModification; import alfio.model.modification.UploadBase64FileModification; import alfio.model.subscription.MaxEntriesOverageDetails; import alfio.model.subscription.SubscriptionDescriptor; import alfio.model.subscription.SubscriptionUsageExceeded; import alfio.model.subscription.SubscriptionUsageExceededForEvent; -import alfio.model.transaction.PaymentProxy; import alfio.repository.*; import alfio.repository.audit.ScanAuditRepository; import alfio.repository.system.ConfigurationRepository; @@ -70,15 +66,11 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; -import java.time.Instant; import java.time.LocalDate; import java.time.LocalTime; -import java.time.ZonedDateTime; import java.util.*; -import static alfio.test.util.IntegrationTestUtil.AVAILABLE_SEATS; -import static alfio.test.util.IntegrationTestUtil.initEvent; -import static java.util.Objects.requireNonNullElse; +import static alfio.test.util.IntegrationTestUtil.*; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @@ -197,42 +189,11 @@ void createContext() { // create subscription descriptor var event = eventAndUser.getLeft(); - var zoneId = clockProvider.getClock().getZone(); - var subscriptionModification = new SubscriptionDescriptorModification(null, - Map.of("en", "title"), - Map.of("en", "description"), - 42, - ZonedDateTime.now(ClockProvider.clock()), - null, - BigDecimal.TEN, - new BigDecimal("7.7"), - PriceContainer.VatStatus.INCLUDED, - "CHF", - false, - event.getOrganizationId(), - 2, - SubscriptionDescriptor.SubscriptionValidityType.CUSTOM, - null, - null, - ZonedDateTime.now(ClockProvider.clock()).minusDays(1), - ZonedDateTime.now(ClockProvider.clock()).plusDays(42), - SubscriptionDescriptor.SubscriptionUsageType.ONCE_PER_EVENT, - "https://example.org", - null, - fileBlobId, - List.of(PaymentProxy.STRIPE), - zoneId); - - var descriptorId = subscriptionManager.createSubscriptionDescriptor(subscriptionModification).orElseThrow(); - var subscriptionId = subscriptionRepository.selectFreeSubscription(descriptorId).orElseThrow(); - var subscriptionReservationId = UUID.randomUUID().toString(); - ticketReservationRepository.createNewReservation(subscriptionReservationId, ZonedDateTime.now(clockProvider.getClock()), Date.from(Instant.now(clockProvider.getClock())), null, "en", null, new BigDecimal("7.7"), true, "CHF", event.getOrganizationId(), null); - subscriptionRepository.bindSubscriptionToReservation(subscriptionReservationId, AllocationStatus.PENDING, subscriptionId); - subscriptionRepository.confirmSubscription(subscriptionReservationId, AllocationStatus.ACQUIRED, - "Test", "Mc Test", "tickettest@test.com", requireNonNullElse(subscriptionModification.getMaxEntries(), -1), - null, null, ZonedDateTime.now(clockProvider.getClock()), zoneId.toString()); - var subscription = subscriptionRepository.findSubscriptionById(subscriptionId); - this.context = new ReservationFlowContext(event, eventAndUser.getRight() + "_owner", subscriptionId, subscription.getPin()); + int maxEntries = 2; + var descriptorId = createSubscriptionDescriptor(event.getOrganizationId(), fileUploadManager, subscriptionManager, maxEntries); + var subscriptionIdAndPin = confirmAndLinkSubscription(descriptorId, event.getOrganizationId(), subscriptionRepository, ticketReservationRepository, maxEntries); + this.subscriptionRepository.linkSubscriptionAndEvent(descriptorId, event.getId(), 0, event.getOrganizationId()); + this.context = new ReservationFlowContext(event, eventAndUser.getRight() + "_owner", subscriptionIdAndPin.getLeft(), subscriptionIdAndPin.getRight()); } @AfterEach diff --git a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java new file mode 100644 index 0000000000..ecfca28ff1 --- /dev/null +++ b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java @@ -0,0 +1,218 @@ +/** + * 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.TestConfiguration; +import alfio.config.DataSourceConfiguration; +import alfio.config.Initializer; +import alfio.controller.api.ControllerConfiguration; +import alfio.manager.AdminReservationRequestManager; +import alfio.manager.EventManager; +import alfio.manager.FileUploadManager; +import alfio.manager.SubscriptionManager; +import alfio.manager.user.UserManager; +import alfio.model.Event; +import alfio.model.Ticket; +import alfio.model.TicketCategory; +import alfio.model.TicketReservation; +import alfio.model.metadata.AlfioMetadata; +import alfio.model.modification.DateTimeModification; +import alfio.model.modification.TicketCategoryModification; +import alfio.model.modification.UploadBase64FileModification; +import alfio.model.system.ConfigurationKeys; +import alfio.model.transaction.PaymentProxy; +import alfio.model.user.Role; +import alfio.model.user.User; +import alfio.repository.*; +import alfio.repository.system.ConfigurationRepository; +import alfio.repository.user.AuthorityRepository; +import alfio.repository.user.OrganizationRepository; +import alfio.repository.user.UserRepository; +import alfio.test.util.IntegrationTestUtil; +import alfio.util.BaseIntegrationTest; +import alfio.util.ClockProvider; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static alfio.test.util.IntegrationTestUtil.*; +import static alfio.test.util.IntegrationTestUtil.confirmAndLinkSubscription; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@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"); + private static final String FIRST_CATEGORY_NAME = "default"; + + private final EventManager eventManager; + private final UserManager userManager; + private final SubscriptionManager subscriptionManager; + private final SubscriptionRepository subscriptionRepository; + private final FileUploadManager fileUploadManager; + private final ConfigurationRepository configurationRepository; + private final OrganizationRepository organizationRepository; + private final EventRepository eventRepository; + private final TicketReservationRepository ticketReservationRepository; + private final AssignTicketToSubscriberJobExecutor executor; + private final AdminReservationRequestRepository adminReservationRequestRepository; + private final AdminReservationRequestManager adminReservationRequestManager; + private final UserRepository userRepository; + private final AuthorityRepository authorityRepository; + private final NamedParameterJdbcTemplate jdbcTemplate; + private final TicketRepository ticketRepository; + private final TicketCategoryRepository ticketCategoryRepository; + + private Event event; + private String userId; + + @Autowired + AssignTicketToSubscriberJobExecutorIntegrationTest(EventManager eventManager, + UserManager userManager, + SubscriptionManager subscriptionManager, + SubscriptionRepository subscriptionRepository, + FileUploadManager fileUploadManager, + ConfigurationRepository configurationRepository, + OrganizationRepository organizationRepository, + EventRepository eventRepository, + TicketReservationRepository ticketReservationRepository, + AssignTicketToSubscriberJobExecutor executor, + AdminReservationRequestRepository adminReservationRequestRepository, + AdminReservationRequestManager adminReservationRequestManager, + UserRepository userRepository, + AuthorityRepository authorityRepository, + NamedParameterJdbcTemplate jdbcTemplate, + TicketRepository ticketRepository, + TicketCategoryRepository ticketCategoryRepository) { + this.eventManager = eventManager; + this.userManager = userManager; + this.subscriptionManager = subscriptionManager; + this.subscriptionRepository = subscriptionRepository; + this.fileUploadManager = fileUploadManager; + this.configurationRepository = configurationRepository; + this.organizationRepository = organizationRepository; + this.eventRepository = eventRepository; + this.ticketReservationRepository = ticketReservationRepository; + this.executor = executor; + this.adminReservationRequestRepository = adminReservationRequestRepository; + this.adminReservationRequestManager = adminReservationRequestManager; + this.userRepository = userRepository; + this.authorityRepository = authorityRepository; + this.jdbcTemplate = jdbcTemplate; + this.ticketRepository = ticketRepository; + this.ticketCategoryRepository = ticketCategoryRepository; + } + + @BeforeEach + void setUp() { + IntegrationTestUtil.ensureMinimalConfiguration(configurationRepository); + List categories = Arrays.asList( + new TicketCategoryModification(null, FIRST_CATEGORY_NAME, TicketCategory.TicketAccessType.INHERIT, AVAILABLE_SEATS, + new DateTimeModification(LocalDate.now(ClockProvider.clock()).minusDays(1), LocalTime.now(ClockProvider.clock())), + new DateTimeModification(LocalDate.now(ClockProvider.clock()).plusDays(1), LocalTime.now(ClockProvider.clock())), + 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.clock()).minusDays(1), LocalTime.now(ClockProvider.clock())), + new DateTimeModification(LocalDate.now(ClockProvider.clock()).plusDays(1), LocalTime.now(ClockProvider.clock())), + DESCRIPTION, BigDecimal.ONE, true, "", true, null, null, null, null, null, 0, null, null, AlfioMetadata.empty()) + ); + Pair eventAndUser = initEvent(categories, organizationRepository, userManager, eventManager, eventRepository); + var uploadFileForm = new UploadBase64FileModification(); + uploadFileForm.setFile(BaseIntegrationTest.ONE_PIXEL_BLACK_GIF); + uploadFileForm.setName("my-image.gif"); + uploadFileForm.setType("image/gif"); + String fileBlobId = fileUploadManager.insertFile(uploadFileForm); + assertNotNull(fileBlobId); + this.event = eventAndUser.getLeft(); + this.userId = eventAndUser.getRight(); + // init admin user + userRepository.create(UserManager.ADMIN_USERNAME, "", "The", "Administrator", "admin@localhost", true, User.Type.INTERNAL, null, null); + authorityRepository.create(UserManager.ADMIN_USERNAME, Role.ADMIN.getRoleName()); + } + + @AfterEach + void tearDown() { + try { + eventManager.deleteEvent(event.getId(), userId); + } catch(Exception ex) { + //ignore exception because the transaction might be aborted + } + } + + @Test + void process() { + int maxEntries = 2; + var descriptorId = createSubscriptionDescriptor(event.getOrganizationId(), fileUploadManager, subscriptionManager, maxEntries); + var subscriptionIdAndPin = confirmAndLinkSubscription(descriptorId, event.getOrganizationId(), subscriptionRepository, ticketReservationRepository, maxEntries); + subscriptionRepository.linkSubscriptionAndEvent(descriptorId, event.getId(), 0, event.getOrganizationId()); + assertEquals(1, subscriptionRepository.loadAvailableSubscriptionsByEvent().size()); + // trigger job schedule with flag not active + executor.process(null); + assertEquals(1, subscriptionRepository.loadAvailableSubscriptionsByEvent().size()); + assertEquals(0, adminReservationRequestRepository.countPending()); + + // try again with flag active + configurationRepository.insert(ConfigurationKeys.GENERATE_TICKETS_FOR_SUBSCRIPTIONS.name(), "true", ""); + executor.process(null); + assertEquals(1, subscriptionRepository.loadAvailableSubscriptionsByEvent().size()); + assertEquals(1, adminReservationRequestRepository.countPending()); + + // trigger reservation processing + var result = adminReservationRequestManager.processPendingReservations(); + assertEquals(1, result.getLeft()); // 1 success + assertEquals(0, result.getRight()); // 0 failures + assertEquals(0, subscriptionRepository.loadAvailableSubscriptionsByEvent().size()); + + // check ticket + var ticketUuid = jdbcTemplate.queryForObject("select uuid from ticket where event_id = :eventId and ext_reference = :ref", + Map.of("eventId", event.getId(), "ref", subscriptionIdAndPin.getLeft() + "_auto"), + String.class); + assertNotNull(ticketUuid); + + // check category + var ticket = ticketRepository.findByUUID(ticketUuid); + assertEquals(Ticket.TicketStatus.ACQUIRED, ticket.getStatus()); + var category = ticketCategoryRepository.getByIdAndActive(ticket.getCategoryId()); + assertTrue(category.isPresent()); + assertEquals(FIRST_CATEGORY_NAME, category.get().getName()); + + // check reservation + var reservation = ticketReservationRepository.findReservationById(ticket.getTicketsReservationId()); + assertEquals(TicketReservation.TicketReservationStatus.COMPLETE, reservation.getStatus()); + assertEquals(PaymentProxy.ADMIN, reservation.getPaymentMethod()); + assertEquals(BigDecimal.ZERO, reservation.getFinalPrice()); + + } +} \ No newline at end of file diff --git a/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java b/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java index 2206e6c0b4..05bbc4b2ef 100644 --- a/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java +++ b/src/test/java/alfio/manager/AdminReservationManagerIntegrationTest.java @@ -179,7 +179,7 @@ public void testReserveFromNewCategory() { Category category = new Category(null, "name", new BigDecimal("100.00"), null); int attendees = AVAILABLE_SEATS; List ticketsInfoList = Collections.singletonList(new TicketsInfo(category, generateAttendees(attendees), true, false)); - AdminReservationModification modification = new AdminReservationModification(expiration, customerData, ticketsInfoList, "en", false, false, null, null, null); + AdminReservationModification modification = new AdminReservationModification(expiration, customerData, ticketsInfoList, "en", false, false, null, null, null, null); Result>> result = adminReservationManager.createReservation(modification, event.getShortName(), username); assertTrue(result.isSuccess()); Pair> data = result.getData(); @@ -218,7 +218,7 @@ public void testReserveMixed() { Category resNewCategory = new Category(null, "name", new BigDecimal("100.00"), null); int attendees = 1; List ticketsInfoList = Arrays.asList(new TicketsInfo(resExistingCategory, generateAttendees(attendees), false, false), new TicketsInfo(resNewCategory, generateAttendees(attendees), false, false),new TicketsInfo(resExistingCategory, generateAttendees(attendees), false, false)); - AdminReservationModification modification = new AdminReservationModification(expiration, customerData, ticketsInfoList, "en", false,false, null, null, null); + AdminReservationModification modification = new AdminReservationModification(expiration, customerData, ticketsInfoList, "en", false,false, null, null, null, null); Result>> result = adminReservationManager.createReservation(modification, event.getShortName(), username); assertTrue(result.isSuccess()); Pair> data = result.getData(); @@ -306,7 +306,7 @@ private Triple performExistingCategoryTest(Lis allAttendees.addAll(attendees); return new TicketsInfo(category, attendees, addSeatsIfNotAvailable, false); }).collect(toList()); - AdminReservationModification modification = new AdminReservationModification(expiration, customerData, ticketsInfoList, "en", false,false, null, null, null); + AdminReservationModification modification = new AdminReservationModification(expiration, customerData, ticketsInfoList, "en", false,false, null, null, null, null); if(reservedTickets > 0) { TicketReservationModification trm = new TicketReservationModification(); @@ -362,7 +362,7 @@ private void validateSuccess(boolean bounded, List attendeesNr, Event e private List generateAttendees(int count) { return IntStream.range(0, count) - .mapToObj(i -> new Attendee(null, "Attendee "+i, "Test" + i, "attendee"+i+"@test.ch", "en",false, null, Collections.emptyMap())) + .mapToObj(i -> new Attendee(null, "Attendee "+i, "Test" + i, "attendee"+i+"@test.ch", "en",false, null, null, Collections.emptyMap())) .collect(toList()); } } \ No newline at end of file diff --git a/src/test/java/alfio/test/util/IntegrationTestUtil.java b/src/test/java/alfio/test/util/IntegrationTestUtil.java index bce145abe6..60156b3f9a 100644 --- a/src/test/java/alfio/test/util/IntegrationTestUtil.java +++ b/src/test/java/alfio/test/util/IntegrationTestUtil.java @@ -17,32 +17,35 @@ package alfio.test.util; import alfio.manager.EventManager; +import alfio.manager.FileUploadManager; +import alfio.manager.SubscriptionManager; import alfio.manager.user.UserManager; +import alfio.model.AllocationStatus; import alfio.model.Event; +import alfio.model.PriceContainer; import alfio.model.metadata.AlfioMetadata; -import alfio.model.modification.DateTimeModification; -import alfio.model.modification.EventModification; -import alfio.model.modification.OrganizationModification; -import alfio.model.modification.TicketCategoryModification; +import alfio.model.modification.*; import alfio.model.modification.support.LocationDescriptor; +import alfio.model.subscription.SubscriptionDescriptor; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.PaymentProxy; import alfio.model.user.Organization; import alfio.model.user.Role; import alfio.model.user.User; import alfio.repository.EventRepository; +import alfio.repository.SubscriptionRepository; +import alfio.repository.TicketReservationRepository; import alfio.repository.system.ConfigurationRepository; import alfio.repository.user.AuthorityRepository; import alfio.repository.user.OrganizationRepository; import alfio.repository.user.UserRepository; +import alfio.util.BaseIntegrationTest; import alfio.util.ClockProvider; import org.apache.commons.lang3.tuple.Pair; import org.junit.jupiter.api.Assertions; import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; +import java.time.*; import java.util.*; public class IntegrationTestUtil { @@ -133,4 +136,58 @@ public static void removeAdminUser(UserRepository userRepository, AuthorityRepos authorityRepository.revokeAll(UserManager.ADMIN_USERNAME); userRepository.deleteUser(userRepository.findIdByUserName(UserManager.ADMIN_USERNAME).orElseThrow()); } + + public static UUID createSubscriptionDescriptor(int organizationId, + FileUploadManager fileUploadManager, + SubscriptionManager subscriptionManager, + int maxEntries) { + var uploadFileForm = new UploadBase64FileModification(); + uploadFileForm.setFile(BaseIntegrationTest.ONE_PIXEL_BLACK_GIF); + uploadFileForm.setName("my-image.gif"); + uploadFileForm.setType("image/gif"); + String fileBlobId = fileUploadManager.insertFile(uploadFileForm); + var subscriptionModification = new SubscriptionDescriptorModification(null, + Map.of("en", "title"), + Map.of("en", "description"), + 42, + ZonedDateTime.now(ClockProvider.clock()), + null, + BigDecimal.TEN, + new BigDecimal("7.7"), + PriceContainer.VatStatus.INCLUDED, + "CHF", + false, + organizationId, + maxEntries, + SubscriptionDescriptor.SubscriptionValidityType.CUSTOM, + null, + null, + ZonedDateTime.now(ClockProvider.clock()).minusDays(1), + ZonedDateTime.now(ClockProvider.clock()).plusDays(42), + SubscriptionDescriptor.SubscriptionUsageType.ONCE_PER_EVENT, + "https://example.org", + null, + fileBlobId, + List.of(PaymentProxy.STRIPE), + ClockProvider.clock().getZone()); + + return subscriptionManager.createSubscriptionDescriptor(subscriptionModification).orElseThrow(); + } + + public static Pair confirmAndLinkSubscription(UUID descriptorId, + int organizationId, + SubscriptionRepository subscriptionRepository, + TicketReservationRepository ticketReservationRepository, + int maxEntries) { + var zoneId = ClockProvider.clock().getZone(); + var subscriptionId = subscriptionRepository.selectFreeSubscription(descriptorId).orElseThrow(); + var subscriptionReservationId = UUID.randomUUID().toString(); + ticketReservationRepository.createNewReservation(subscriptionReservationId, ZonedDateTime.now(ClockProvider.clock()), Date.from(Instant.now(ClockProvider.clock())), null, "en", null, new BigDecimal("7.7"), true, "CHF", organizationId, null); + subscriptionRepository.bindSubscriptionToReservation(subscriptionReservationId, AllocationStatus.PENDING, subscriptionId); + subscriptionRepository.confirmSubscription(subscriptionReservationId, AllocationStatus.ACQUIRED, + "Test", "Mc Test", "tickettest@test.com", maxEntries, + null, null, ZonedDateTime.now(ClockProvider.clock()), zoneId.toString()); + var subscription = subscriptionRepository.findSubscriptionById(subscriptionId); + return Pair.of(subscriptionId, subscription.getPin()); + } } From 89af6bee1fcd4b20ec5a51d83a24594b8c077aa9 Mon Sep 17 00:00:00 2001 From: Celestino Bellone Date: Tue, 30 Nov 2021 19:33:46 +0100 Subject: [PATCH 2/4] send tickets via email --- .../AssignTicketToSubscriberJobExecutor.java | 7 ++---- ...oSubscriberJobExecutorIntegrationTest.java | 22 +++++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java b/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java index 22bc4bfbd7..8733282ea3 100644 --- a/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java +++ b/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java @@ -22,10 +22,7 @@ import alfio.model.Event; import alfio.model.TicketCategory; import alfio.model.modification.AdminReservationModification; -import alfio.model.modification.AdminReservationModification.Attendee; -import alfio.model.modification.AdminReservationModification.Category; -import alfio.model.modification.AdminReservationModification.CustomerData; -import alfio.model.modification.AdminReservationModification.TicketsInfo; +import alfio.model.modification.AdminReservationModification.*; import alfio.model.modification.DateTimeModification; import alfio.model.subscription.AvailableSubscriptionsByEvent; import alfio.model.system.AdminJobSchedule; @@ -130,7 +127,7 @@ private AdminReservationModification buildBody(Event event, false, false, null, - null, + new Notification(false, true), null, null ); diff --git a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java index ecfca28ff1..7e4a02adbf 100644 --- a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java +++ b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java @@ -20,15 +20,9 @@ import alfio.config.DataSourceConfiguration; import alfio.config.Initializer; import alfio.controller.api.ControllerConfiguration; -import alfio.manager.AdminReservationRequestManager; -import alfio.manager.EventManager; -import alfio.manager.FileUploadManager; -import alfio.manager.SubscriptionManager; +import alfio.manager.*; import alfio.manager.user.UserManager; -import alfio.model.Event; -import alfio.model.Ticket; -import alfio.model.TicketCategory; -import alfio.model.TicketReservation; +import alfio.model.*; import alfio.model.metadata.AlfioMetadata; import alfio.model.modification.DateTimeModification; import alfio.model.modification.TicketCategoryModification; @@ -94,6 +88,7 @@ class AssignTicketToSubscriberJobExecutorIntegrationTest { private final NamedParameterJdbcTemplate jdbcTemplate; private final TicketRepository ticketRepository; private final TicketCategoryRepository ticketCategoryRepository; + private final NotificationManager notificationManager; private Event event; private String userId; @@ -115,7 +110,8 @@ class AssignTicketToSubscriberJobExecutorIntegrationTest { AuthorityRepository authorityRepository, NamedParameterJdbcTemplate jdbcTemplate, TicketRepository ticketRepository, - TicketCategoryRepository ticketCategoryRepository) { + TicketCategoryRepository ticketCategoryRepository, + NotificationManager notificationManager) { this.eventManager = eventManager; this.userManager = userManager; this.subscriptionManager = subscriptionManager; @@ -133,6 +129,7 @@ class AssignTicketToSubscriberJobExecutorIntegrationTest { this.jdbcTemplate = jdbcTemplate; this.ticketRepository = ticketRepository; this.ticketCategoryRepository = ticketCategoryRepository; + this.notificationManager = notificationManager; } @BeforeEach @@ -214,5 +211,12 @@ void process() { assertEquals(PaymentProxy.ADMIN, reservation.getPaymentMethod()); assertEquals(BigDecimal.ZERO, reservation.getFinalPrice()); + // trigger email send + int sent = notificationManager.sendWaitingMessages(); + assertTrue(sent > 0); + var messagesPair = notificationManager.loadAllMessagesForPurchaseContext(event, null, null); + assertEquals(1, messagesPair.getLeft()); + assertTrue(messagesPair.getRight().stream().allMatch(m -> m.getStatus() == EmailMessage.Status.SENT)); + } } \ No newline at end of file From e6fa144dc310bc6b9d84c37ba1fc473efc912da5 Mon Sep 17 00:00:00 2001 From: Celestino Bellone Date: Mon, 6 Dec 2021 11:02:34 +0100 Subject: [PATCH 3/4] select first available category --- .../AssignTicketToSubscriberJobExecutor.java | 10 +++++++--- .../alfio/repository/TicketCategoryRepository.java | 9 +++++++++ ...TicketToSubscriberJobExecutorIntegrationTest.java | 12 +++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java b/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java index 8733282ea3..acb28efc64 100644 --- a/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java +++ b/src/main/java/alfio/job/executor/AssignTicketToSubscriberJobExecutor.java @@ -30,6 +30,7 @@ import alfio.repository.SubscriptionRepository; import alfio.repository.TicketCategoryRepository; import alfio.util.ClockProvider; +import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Component; import java.time.LocalDate; @@ -46,6 +47,7 @@ import static alfio.model.system.ConfigurationKeys.GENERATE_TICKETS_FOR_SUBSCRIPTIONS; @Component +@Log4j2 public class AssignTicketToSubscriberJobExecutor implements AdminJobExecutor { private final AdminReservationRequestManager requestManager; @@ -92,9 +94,9 @@ public String process(AdminJobSchedule schedule) { .getValueAsBooleanOrDefault(); if (generationEnabled) { var subscriptions = subscriptionsByEvent.get(event.getId()); - var categories = ticketCategoryRepository.findAllTicketCategories(event.getId()); - if (!categories.isEmpty()) { - var category = categories.get(0); + var optionalCategory = ticketCategoryRepository.findFirstWithAvailableTickets(event.getId()); + if (optionalCategory.isPresent()) { + var category = optionalCategory.get(); // 3. create reservation import request for the subscribers. ID is "AUTO_${eventShortName}_${now_ISO}" var requestId = String.format("AUTO_%s_%s", event.getShortName(), LocalDateTime.now(clockProvider.getClock()).format(DateTimeFormatter.ISO_DATE_TIME)); requestManager.insertRequest(requestId, @@ -103,6 +105,8 @@ public String process(AdminJobSchedule schedule) { false, "admin"); + } else { + log.warn("Cannot find a suitable ticket category for event {}", event.getId()); } } }); diff --git a/src/main/java/alfio/repository/TicketCategoryRepository.java b/src/main/java/alfio/repository/TicketCategoryRepository.java index f45e460de3..0c7479d973 100644 --- a/src/main/java/alfio/repository/TicketCategoryRepository.java +++ b/src/main/java/alfio/repository/TicketCategoryRepository.java @@ -85,6 +85,15 @@ AffectedRowCountAndKey insert(@Bind("inception") ZonedDateTime inceptio @Query("select * from ticket_category_with_currency where event_id = :eventId and tc_status = 'ACTIVE' order by ordinal asc, inception asc, expiration asc, id asc") List findAllTicketCategories(@Bind("eventId") int eventId); + @Query("select tc.* from ticket_category_with_currency tc" + + " join ticket_category_statistics tcs on tc.id = tcs.ticket_category_id" + + " where tc.event_id = :eventId" + + " and tcs.is_expired is FALSE" + + " and tcs.access_restricted is FALSE" + + " and (tcs.bounded is FALSE or tcs.not_sold_tickets > 0)" + + " limit 1") + Optional findFirstWithAvailableTickets(@Bind("eventId") int eventId); + default Map findByEventIdAsMap(int eventId) { return findAllTicketCategories(eventId).stream().collect(Collectors.toMap(TicketCategory::getId, Function.identity())); } diff --git a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java index 7e4a02adbf..db39b7f11d 100644 --- a/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java +++ b/src/test/java/alfio/job/executor/AssignTicketToSubscriberJobExecutorIntegrationTest.java @@ -53,13 +53,11 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalTime; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import static alfio.test.util.IntegrationTestUtil.*; -import static alfio.test.util.IntegrationTestUtil.confirmAndLinkSubscription; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @@ -135,15 +133,15 @@ class AssignTicketToSubscriberJobExecutorIntegrationTest { @BeforeEach void setUp() { IntegrationTestUtil.ensureMinimalConfiguration(configurationRepository); - List categories = Arrays.asList( - new TicketCategoryModification(null, FIRST_CATEGORY_NAME, TicketCategory.TicketAccessType.INHERIT, AVAILABLE_SEATS, + List categories = List.of( + new TicketCategoryModification(null, "hidden", TicketCategory.TicketAccessType.INHERIT, 2, new DateTimeModification(LocalDate.now(ClockProvider.clock()).minusDays(1), LocalTime.now(ClockProvider.clock())), new DateTimeModification(LocalDate.now(ClockProvider.clock()).plusDays(1), LocalTime.now(ClockProvider.clock())), - DESCRIPTION, BigDecimal.TEN, false, "", false, null, null, null, null, null, 0, null, null, AlfioMetadata.empty()), - new TicketCategoryModification(null, "hidden", TicketCategory.TicketAccessType.INHERIT, 2, + DESCRIPTION, BigDecimal.ONE, true, "", true, null, null, null, null, null, 0, null, null, AlfioMetadata.empty()), + new TicketCategoryModification(null, FIRST_CATEGORY_NAME, TicketCategory.TicketAccessType.INHERIT, AVAILABLE_SEATS, new DateTimeModification(LocalDate.now(ClockProvider.clock()).minusDays(1), LocalTime.now(ClockProvider.clock())), new DateTimeModification(LocalDate.now(ClockProvider.clock()).plusDays(1), LocalTime.now(ClockProvider.clock())), - DESCRIPTION, BigDecimal.ONE, true, "", true, null, null, null, null, null, 0, null, null, AlfioMetadata.empty()) + DESCRIPTION, BigDecimal.TEN, false, "", false, null, null, null, null, null, 0, null, null, AlfioMetadata.empty()) ); Pair eventAndUser = initEvent(categories, organizationRepository, userManager, eventManager, eventRepository); var uploadFileForm = new UploadBase64FileModification(); From c209511712ba9914ac909d3b2ecb9f4a3f97c714 Mon Sep 17 00:00:00 2001 From: Celestino Bellone Date: Mon, 6 Dec 2021 11:58:53 +0100 Subject: [PATCH 4/4] sort categories by (inception, expiration) date and id in order to be consistent --- src/main/java/alfio/repository/TicketCategoryRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/alfio/repository/TicketCategoryRepository.java b/src/main/java/alfio/repository/TicketCategoryRepository.java index 0c7479d973..09f1a9eb5b 100644 --- a/src/main/java/alfio/repository/TicketCategoryRepository.java +++ b/src/main/java/alfio/repository/TicketCategoryRepository.java @@ -91,6 +91,7 @@ AffectedRowCountAndKey insert(@Bind("inception") ZonedDateTime inceptio " and tcs.is_expired is FALSE" + " and tcs.access_restricted is FALSE" + " and (tcs.bounded is FALSE or tcs.not_sold_tickets > 0)" + + " order by tc.inception, tc.expiration, tc.id" + " limit 1") Optional findFirstWithAvailableTickets(@Bind("eventId") int eventId);