diff --git a/.github/workflows/tmp-feature-subscription-build.yml b/.github/workflows/tmp-feature-subscription-build.yml new file mode 100644 index 0000000000..633e7913e5 --- /dev/null +++ b/.github/workflows/tmp-feature-subscription-build.yml @@ -0,0 +1,59 @@ +name: tmp-feature-subscription-build + +on: + push: + branches: + - feature-subscription + +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: alfio + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: | + ${{ runner.os }}-gradle- + - uses: actions/cache@v1 + with: + path: ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradlew') }} + restore-keys: | + ${{ runner.os }}-gradlew- + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Build with Gradle + run: ./gradlew build distribution jacocoTestReport -Dspring.profiles.active=travis -Ddbenv=PGSQL-TRAVIS -Dpgsql${{ matrix.postgresql }} + - name: Configure Docker + if: ${{ github.repository == 'alfio-event/alf.io' }} + uses: docker/setup-buildx-action@v1 + - name: Login to Container Registry + if: ${{ github.repository == 'alfio-event/alf.io' }} + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + - name: Push Docker image + if: ${{ github.repository == 'alfio-event/alf.io' }} + uses: docker/build-push-action@v2 + with: + context: ./build/dockerize + tags: | + ghcr.io/alfio-event/alf.io/dev-feature-subscription:latest + push: true \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index aca58d3f1a..1073a9d803 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,4 +13,4 @@ junitVersion=5.1.0 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=0c79c9907e \ No newline at end of file +alfioPublicFrontendVersion=dfe4d8bb47 \ No newline at end of file diff --git a/src/main/java/alfio/config/WebSecurityConfig.java b/src/main/java/alfio/config/WebSecurityConfig.java index 5c32281273..bfa377a7fb 100644 --- a/src/main/java/alfio/config/WebSecurityConfig.java +++ b/src/main/java/alfio/config/WebSecurityConfig.java @@ -286,13 +286,16 @@ protected void configure(HttpSecurity http) throws Exception { ADMIN_API + "/overridable-template/", ADMIN_API + "/events/*/promo-code", ADMIN_API + "/reservation/event/*/reservations/list", - ADMIN_API + "/events/*/email/", + ADMIN_API + "/event/*/email/", ADMIN_API + "/event/*/waiting-queue/load", ADMIN_API + "/events/*/pending-payments", ADMIN_API + "/events/*/export", ADMIN_API + "/events/*/sponsor-scan/export", ADMIN_API + "/events/*/invoices/**", - ADMIN_API + "/reservation/event/*/*/audit" + ADMIN_API + "/reservation/*/*/*/audit", + ADMIN_API + "/subscription/*/email/", + ADMIN_API + "/organization/*/subscription/**", + ADMIN_API + "/reservation/subscription/**" }; configurer.csrfTokenRepository(csrfTokenRepository) diff --git a/src/main/java/alfio/controller/IndexController.java b/src/main/java/alfio/controller/IndexController.java index 1d3ca03f95..2f38f44fed 100644 --- a/src/main/java/alfio/controller/IndexController.java +++ b/src/main/java/alfio/controller/IndexController.java @@ -29,10 +29,7 @@ import alfio.model.TicketReservationStatusAndValidation; import alfio.model.system.ConfigurationKeys; import alfio.model.user.Role; -import alfio.repository.EventDescriptionRepository; -import alfio.repository.EventRepository; -import alfio.repository.FileUploadRepository; -import alfio.repository.TicketReservationRepository; +import alfio.repository.*; import alfio.repository.user.OrganizationRepository; import alfio.util.Json; import alfio.util.MustacheCustomTag; @@ -105,6 +102,7 @@ public class IndexController { private final EventDescriptionRepository eventDescriptionRepository; private final OrganizationRepository organizationRepository; private final TicketReservationRepository ticketReservationRepository; + private final SubscriptionRepository subscriptionRepository; private final EventLoader eventLoader; @@ -152,6 +150,7 @@ public ResponseEntity replyToK8s() { */ @GetMapping({ "/", + "/events-all", "/event/{eventShortName}", "/event/{eventShortName}/reservation/{reservationId}/book", "/event/{eventShortName}/reservation/{reservationId}/overview", @@ -165,11 +164,24 @@ public ResponseEntity replyToK8s() { "/event/{eventShortName}/ticket/{ticketId}/view", "/event/{eventShortName}/ticket/{ticketId}/update", // + // subscription + "/subscriptions-all", + "/subscription/{subscriptionId}", + "/subscription/{subscriptionId}/reservation/{reservationId}/book", + "/subscription/{subscriptionId}/reservation/{reservationId}/overview", + "/subscription/{subscriptionId}/reservation/{reservationId}/waitingPayment", + "/subscription/{subscriptionId}/reservation/{reservationId}/waiting-payment", + "/subscription/{subscriptionId}/reservation/{reservationId}/deferred-payment", + "/subscription/{subscriptionId}/reservation/{reservationId}/processing-payment", + "/subscription/{subscriptionId}/reservation/{reservationId}/success", + "/subscription/{subscriptionId}/reservation/{reservationId}/not-found", + "/subscription/{subscriptionId}/reservation/{reservationId}/error", // poll "/event/{eventShortName}/poll", "/event/{eventShortName}/poll/{pollId}" }) public void replyToIndex(@PathVariable(value = "eventShortName", required = false) String eventShortName, + @PathVariable(value = "subscriptionId", required = false) String subscriptionId, @RequestHeader(value = "User-Agent", required = false) String userAgent, @RequestParam(value = "lang", required = false) String lang, ServletWebRequest request, @@ -210,7 +222,7 @@ public void replyToIndex(@PathVariable(value = "eventShortName", required = fals } @GetMapping("/event/{eventShortName}/reservation/{reservationId}") - public String redirectToReservation(@PathVariable(value = "eventShortName") String eventShortName, @PathVariable(value = "reservationId") String reservationId) { + public String redirectEventToReservation(@PathVariable(value = "eventShortName") String eventShortName, @PathVariable(value = "reservationId") String reservationId) { if (eventRepository.existsByShortName(eventShortName)) { var reservationStatusUrlSegment = ticketReservationRepository.findOptionalStatusAndValidationById(reservationId) .map(IndexController::reservationStatusToUrlMapping).orElse("not-found"); @@ -223,6 +235,20 @@ public String redirectToReservation(@PathVariable(value = "eventShortName") Stri } } + @GetMapping("/subscription/{subscriptionId}/reservation/{reservationId}") + public String redirectSubscriptionToReservation(@PathVariable("subscriptionId") String subscriptionId, @PathVariable("reservationId") String reservationId) { + if (subscriptionRepository.existsById(UUID.fromString(subscriptionId))) { + var reservationStatusUrlSegment = ticketReservationRepository.findOptionalStatusAndValidationById(reservationId) + .map(IndexController::reservationStatusToUrlMapping).orElse("not-found"); + + return "redirect:" + UriComponentsBuilder.fromPath("/subscription/{subscriptionId}/reservation/{reservationId}/{status}") + .buildAndExpand(Map.of("subscriptionId", subscriptionId, "reservationId", reservationId, "status",reservationStatusUrlSegment)) + .toUriString(); + } else { + return "redirect:/"; + } + } + private static Element buildScripTag(String content, String type, String id, String param) { var e = new Element("script"); e.appendChild(new Text(content)); @@ -259,7 +285,7 @@ private Document getOpenGraphPage(Document eventOpenGraph, String eventShortName var baseUrl = configurationManager.getForSystem(ConfigurationKeys.BASE_URL).getRequiredValue(); - var title = messageSourceManager.getMessageSourceForEvent(event).getMessage("event.get-your-ticket-for", new String[] {event.getDisplayName()}, locale); + var title = messageSourceManager.getMessageSourceFor(event).getMessage("event.get-your-ticket-for", new String[] {event.getDisplayName()}, locale); var head = eventOpenGraph.getElementsByTagName("head").get(0); diff --git a/src/main/java/alfio/controller/api/admin/AdminReservationApiController.java b/src/main/java/alfio/controller/api/admin/AdminReservationApiController.java index d9d22933e0..9832ae465b 100644 --- a/src/main/java/alfio/controller/api/admin/AdminReservationApiController.java +++ b/src/main/java/alfio/controller/api/admin/AdminReservationApiController.java @@ -17,10 +17,9 @@ package alfio.controller.api.admin; import alfio.controller.api.support.PageAndContent; -import alfio.manager.AdminReservationManager; -import alfio.manager.EventManager; -import alfio.manager.TicketReservationManager; +import alfio.manager.*; import alfio.model.*; +import alfio.model.PurchaseContext.PurchaseContextType; import alfio.model.modification.AdminReservationModification; import alfio.model.result.ErrorCode; import alfio.model.result.Result; @@ -50,74 +49,92 @@ public class AdminReservationApiController { private final AdminReservationManager adminReservationManager; private final EventManager eventManager; + private final PurchaseContextManager purchaseContextManager; + private final PurchaseContextSearchManager purchaseContextSearchManager; private final TicketReservationManager ticketReservationManager; - @PostMapping("/event/{eventName}/new") - public Result createNew(@PathVariable("eventName") String eventName, @RequestBody AdminReservationModification reservation, Principal principal) { - return adminReservationManager.createReservation(reservation, eventName, principal.getName()).map(r -> r.getLeft().getId()); + @PostMapping("/{purchaseContextType}/{publicIdentifier}/new") + public Result createNew(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @RequestBody AdminReservationModification reservation, Principal principal) { + if(purchaseContextType != PurchaseContextType.event) { + return Result.error(ErrorCode.EventError.NOT_FOUND); + } + return adminReservationManager.createReservation(reservation, publicIdentifier, principal.getName()).map(r -> r.getLeft().getId()); } - @GetMapping("/event/{eventName}/reservations/all-status") - public TicketReservation.TicketReservationStatus[] getAllStatus(@PathVariable("eventName") String eventName) { + @GetMapping("/{purchaseContextType}/{publicIdentifier}/reservations/all-status") + public TicketReservation.TicketReservationStatus[] getAllStatus(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier) { return TicketReservation.TicketReservationStatus.values(); } - @GetMapping("/event/{eventName}/reservations/list") - public PageAndContent> findAll(@PathVariable("eventName") String eventName, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "search", required = false) String search, - @RequestParam(value = "status", required = false) List status, - Principal principal) { - return eventManager.getOptionalEventAndOrganizationIdByName(eventName, principal.getName()) - .map(event -> { - Pair, Integer> res = ticketReservationManager.findAllReservationsInEvent(event.getId(), page, search, status); + @GetMapping("/{purchaseContextType}/{publicIdentifier}/reservations/list") + public PageAndContent> findAll(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "search", required = false) String search, + @RequestParam(value = "status", required = false) List status) { + + return purchaseContextManager.findBy(purchaseContextType, publicIdentifier) + .map(purchaseContext -> { + Pair, Integer> res = purchaseContextSearchManager.findAllReservationsFor(purchaseContext, page, search, status); return new PageAndContent<>(res.getLeft(), res.getRight()); }).orElseGet(() -> new PageAndContent<>(Collections.emptyList(), 0)); } - @PutMapping("/event/{eventName}/{reservationId}/confirm") - public Result confirmReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.confirmReservation(eventName, reservationId, principal.getName(), AdminReservationModification.Notification.EMPTY) + @PutMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/confirm") + public Result confirmReservation(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @PathVariable("reservationId") String reservationId, + Principal principal) { + return adminReservationManager.confirmReservation(purchaseContextType, publicIdentifier, reservationId, principal.getName(), AdminReservationModification.Notification.EMPTY) .map(triple -> toReservationDescriptor(reservationId, triple)); } - @PostMapping("/event/{eventName}/{reservationId}") - public Result updateReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, - @RequestBody AdminReservationModification arm, Principal principal) { - return adminReservationManager.updateReservation(eventName, reservationId, arm, principal.getName()); + @PostMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}") + public Result updateReservation(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @PathVariable("reservationId") String reservationId, + @RequestBody AdminReservationModification arm, + Principal principal) { + return adminReservationManager.updateReservation(purchaseContextType, publicIdentifier, reservationId, arm, principal.getName()); } - @PutMapping("/event/{eventName}/{reservationId}/notify") - public Result notifyReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, - @RequestBody AdminReservationModification arm, Principal principal) { - return adminReservationManager.notify(eventName, reservationId, arm, principal.getName()); + @PutMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/notify") + public Result notifyReservation(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @PathVariable("reservationId") String reservationId, + @RequestBody AdminReservationModification arm, + Principal principal) { + return adminReservationManager.notify(purchaseContextType, publicIdentifier, reservationId, arm, principal.getName()); } - @PutMapping("/event/{eventName}/{reservationId}/notify-attendees") - public Result notifyAttendees(@PathVariable("eventName") String eventName, + @PutMapping("/event/{publicIdentifier}/{reservationId}/notify-attendees") + public Result notifyAttendees(@PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @RequestBody List ids, Principal principal) { - return adminReservationManager.notifyAttendees(eventName, reservationId, ids, principal.getName()); + return adminReservationManager.notifyAttendees(publicIdentifier, reservationId, ids, principal.getName()); } - @GetMapping("/event/{eventName}/{reservationId}/audit") - public Result> getAudit(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.getAudit(eventName, reservationId, principal.getName()); + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/audit") + public Result> getAudit(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @PathVariable("reservationId") String reservationId, Principal principal) { + return adminReservationManager.getAudit(purchaseContextType, publicIdentifier, reservationId, principal.getName()); } - @GetMapping("/event/{eventName}/{reservationId}/billing-documents") - public Result> getBillingDocuments(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.getBillingDocuments(eventName, reservationId, principal.getName()); + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/billing-documents") + public Result> getBillingDocuments(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, Principal principal) { + return adminReservationManager.getBillingDocuments(publicIdentifier, reservationId, principal.getName()); } - @DeleteMapping("/event/{eventName}/{reservationId}/billing-document/{documentId}") - public ResponseEntity invalidateBillingDocument(@PathVariable("eventName") String eventName, + @DeleteMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/billing-document/{documentId}") + public ResponseEntity invalidateBillingDocument(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @PathVariable("documentId") long documentId, Principal principal) { - Result invalidateResult = adminReservationManager.invalidateBillingDocument(eventName, reservationId, documentId, principal.getName()); + Result invalidateResult = adminReservationManager.invalidateBillingDocument(reservationId, documentId, principal.getName()); if(invalidateResult.isSuccess()) { return ResponseEntity.ok(invalidateResult.getData()); } else { @@ -125,12 +142,13 @@ public ResponseEntity invalidateBillingDocument(@PathVariable("eventNam } } - @PutMapping("/event/{eventName}/{reservationId}/billing-document/{documentId}/restore") - public ResponseEntity restoreBillingDocument(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, - @PathVariable("documentId") long documentId, - Principal principal) { - Result restoreResult = adminReservationManager.restoreBillingDocument(eventName, reservationId, documentId, principal.getName()); + @PutMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/billing-document/{documentId}/restore") + public ResponseEntity restoreBillingDocument(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @PathVariable("reservationId") String reservationId, + @PathVariable("documentId") long documentId, + Principal principal) { + Result restoreResult = adminReservationManager.restoreBillingDocument(reservationId, documentId, principal.getName()); if(restoreResult.isSuccess()) { return ResponseEntity.ok(restoreResult.getData()); } else { @@ -138,14 +156,15 @@ public ResponseEntity restoreBillingDocument(@PathVariable("eventName") } } - @GetMapping("/event/{eventName}/{reservationId}/billing-document/{documentId}") - public ResponseEntity getBillingDocument(@PathVariable("eventName") String eventName, + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/billing-document/{documentId}") + public ResponseEntity getBillingDocument(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @PathVariable("documentId") long documentId, Principal principal, HttpServletResponse response) { - Result result = adminReservationManager.getSingleBillingDocumentAsPdf(eventName, reservationId, documentId, principal.getName()) - .map(res -> sendPdf(res.getRight(), response, eventName, reservationId, res.getLeft())); + Result result = adminReservationManager.getSingleBillingDocumentAsPdf(purchaseContextType, publicIdentifier, reservationId, documentId, principal.getName()) + .map(res -> sendPdf(res.getRight(), response, publicIdentifier, reservationId, res.getLeft())); if(result.isSuccess()) { return ResponseEntity.ok(null); } else { @@ -153,15 +172,15 @@ public ResponseEntity getBillingDocument(@PathVariable("eventName") String } } - @GetMapping("/event/{eventName}/{reservationId}") - public Result loadReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.loadReservation(eventName, reservationId, principal.getName()) + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}") + public Result loadReservation(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, Principal principal) { + return adminReservationManager.loadReservation(purchaseContextType, publicIdentifier, reservationId, principal.getName()) .map(triple -> toReservationDescriptor(reservationId, triple)); } - @GetMapping("/event/{eventName}/{reservationId}/ticket/{ticketId}") - public Result loadTicket(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @PathVariable("ticketId") int ticketId, Principal principal) { - return adminReservationManager.loadReservation(eventName, reservationId, principal.getName()).flatMap(triple -> + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/ticket/{ticketId}") + public Result loadTicket(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @PathVariable("ticketId") int ticketId, Principal principal) { + return adminReservationManager.loadReservation(purchaseContextType, publicIdentifier, reservationId, principal.getName()).flatMap(triple -> //not optimal triple.getMiddle().stream() .filter(t -> t.getId() == ticketId) @@ -172,8 +191,8 @@ public Result loadTicket(@PathVariable("eventName") String eventName, @P } - @PostMapping("/event/{eventName}/{reservationId}/remove-tickets") - public Result removeTickets(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, + @PostMapping("/event/{publicIdentifier}/{reservationId}/remove-tickets") + public Result removeTickets(@PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @RequestBody RemoveTicketsModification toRemove, Principal principal) { List toRefund = toRemove.getRefundTo().entrySet().stream() @@ -181,48 +200,48 @@ public Result removeTickets(@PathVariable("eventName") String eventName .map(Map.Entry::getKey) .collect(Collectors.toList()); - adminReservationManager.removeTickets(eventName, reservationId, toRemove.getTicketIds(), toRefund, toRemove.getNotify(), toRemove.getForceInvoiceUpdate(), principal.getName()); + adminReservationManager.removeTickets(publicIdentifier, reservationId, toRemove.getTicketIds(), toRefund, toRemove.getNotify(), toRemove.getForceInvoiceUpdate(), principal.getName()); return Result.success(true); } - @GetMapping("/event/{eventName}/{reservationId}/payment-info") - public Result getPaymentInfo(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.getPaymentInfo(eventName, reservationId, principal.getName()); + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/payment-info") + public Result getPaymentInfo(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, Principal principal) { + return adminReservationManager.getPaymentInfo(reservationId); } - @PostMapping("/event/{eventName}/{reservationId}/cancel") - public Result removeReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @RequestParam("refund") boolean refund, + @PostMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/cancel") + public Result removeReservation(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @RequestParam("refund") boolean refund, @RequestParam(value = "notify", defaultValue = "false") boolean notify, Principal principal) { - return adminReservationManager.removeReservation(eventName, reservationId, refund, notify, principal.getName()); + return adminReservationManager.removeReservation(purchaseContextType, publicIdentifier, reservationId, refund, notify, principal.getName()); } - @PostMapping("/event/{eventName}/{reservationId}/credit") - public Result creditReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @RequestParam("refund") boolean refund, + @PostMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/credit") + public Result creditReservation(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @RequestParam("refund") boolean refund, @RequestParam(value = "notify", defaultValue = "false") boolean notify, Principal principal) { - adminReservationManager.creditReservation(eventName, reservationId, refund, notify, principal.getName()); + adminReservationManager.creditReservation(purchaseContextType, publicIdentifier, reservationId, refund, notify, principal.getName()); return Result.success(true); } - @PutMapping("/event/{eventName}/{reservationId}/regenerate-billing-document") - public Result regenerateBillingDocument(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.regenerateBillingDocument(eventName, reservationId, principal.getName()); + @PutMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/regenerate-billing-document") + public Result regenerateBillingDocument(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, Principal principal) { + return adminReservationManager.regenerateBillingDocument(purchaseContextType, publicIdentifier, reservationId, principal.getName()); } - @PostMapping("/event/{eventName}/{reservationId}/refund") - public Result refund(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @RequestBody RefundAmount amount, Principal principal) { - return adminReservationManager.refund(eventName, reservationId, new BigDecimal(amount.amount), principal.getName()); + @PostMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/refund") + public Result refund(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @RequestBody RefundAmount amount, Principal principal) { + return adminReservationManager.refund(purchaseContextType, publicIdentifier, reservationId, new BigDecimal(amount.amount), principal.getName()); } - @GetMapping("/event/{eventName}/{reservationId}/email-list") - public Result> getEmailList(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) { - return adminReservationManager.getEmailsForReservation(eventName, reservationId, principal.getName()); + @GetMapping("/{purchaseContextType}/{publicIdentifier}/{reservationId}/email-list") + public Result> getEmailList(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, Principal principal) { + return adminReservationManager.getEmailsForReservation(purchaseContextType, publicIdentifier, reservationId, principal.getName()); } - private TicketReservationDescriptor toReservationDescriptor(String reservationId, Triple, Event> triple) { + private TicketReservationDescriptor toReservationDescriptor(String reservationId, Triple, PurchaseContext> triple) { List>> tickets = triple.getMiddle().stream().collect(Collectors.groupingBy(Ticket::getCategoryId)).entrySet().stream() - .map(entry -> SerializablePair.of(eventManager.getTicketCategoryById(entry.getKey(), triple.getRight().getId()), entry.getValue())) + .map(entry -> SerializablePair.of(eventManager.getTicketCategoryById(entry.getKey(), triple.getRight().event().orElseThrow().getId()), entry.getValue())) .collect(Collectors.toList()); TicketReservation reservation = triple.getLeft(); return new TicketReservationDescriptor(reservation, diff --git a/src/main/java/alfio/controller/api/admin/AdminWaitingQueueApiController.java b/src/main/java/alfio/controller/api/admin/AdminWaitingQueueApiController.java index ab7ab9a3e4..f5fca6044d 100644 --- a/src/main/java/alfio/controller/api/admin/AdminWaitingQueueApiController.java +++ b/src/main/java/alfio/controller/api/admin/AdminWaitingQueueApiController.java @@ -21,7 +21,6 @@ import alfio.manager.EventStatisticsManager; import alfio.manager.TicketReservationManager; import alfio.manager.WaitingQueueManager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.Event; import alfio.model.WaitingQueueSubscription; @@ -75,7 +74,7 @@ private Map loadStatus(Event event) { .map(tc -> new SaleableTicketCategory(tc, now, event, ticketReservationManager.countAvailableTickets(event, tc), tc.getMaxTickets(), null)) .collect(Collectors.toList()); boolean active = EventUtil.checkWaitingQueuePreconditions(event, stcList, configurationManager, eventStatisticsManager.noSeatsAvailable()); - boolean paused = active && configurationManager.getFor(STOP_WAITING_QUEUE_SUBSCRIPTIONS, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + boolean paused = active && configurationManager.getFor(STOP_WAITING_QUEUE_SUBSCRIPTIONS, event.getConfigurationLevel()).getValueAsBooleanOrDefault(); Map result = new HashMap<>(); result.put("active", active); result.put("paused", paused); diff --git a/src/main/java/alfio/controller/api/admin/CheckInApiController.java b/src/main/java/alfio/controller/api/admin/CheckInApiController.java index a9468eece8..552bfef40d 100644 --- a/src/main/java/alfio/controller/api/admin/CheckInApiController.java +++ b/src/main/java/alfio/controller/api/admin/CheckInApiController.java @@ -20,7 +20,6 @@ import alfio.manager.EventManager; import alfio.manager.support.CheckInStatistics; import alfio.manager.support.TicketAndCheckInResult; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.EventAndOrganizationId; import alfio.model.FullTicketInfo; @@ -236,7 +235,7 @@ private ResponseEntity parseLabelLayout(EventAndOrganizationId even } private Optional loadLabelLayout(EventAndOrganizationId event) { - return configurationManager.getFor(ConfigurationKeys.LABEL_LAYOUT, ConfigurationLevel.event(event)).getValue() + return configurationManager.getFor(ConfigurationKeys.LABEL_LAYOUT, event.getConfigurationLevel()).getValue() .flatMap(str -> optionally(() -> Json.fromJson(str, LabelLayout.class))); } diff --git a/src/main/java/alfio/controller/api/admin/ConfigurationApiController.java b/src/main/java/alfio/controller/api/admin/ConfigurationApiController.java index 896b6e7664..4099507fe1 100644 --- a/src/main/java/alfio/controller/api/admin/ConfigurationApiController.java +++ b/src/main/java/alfio/controller/api/admin/ConfigurationApiController.java @@ -95,18 +95,37 @@ public Map> loadEventConf return configurationManager.loadEventConfig(eventId, principal.getName()); } - @GetMapping("/events/{eventId}/single/{key}") - public ResponseEntity getSingleConfigForEvent(@PathVariable("eventId") int eventId, + @GetMapping("/events/{eventName}/single/{key}") + public ResponseEntity getSingleConfigForEvent(@PathVariable("eventName") String eventShortName, @PathVariable("key") String key, Principal principal) { - String singleConfigForEvent = configurationManager.getSingleConfigForEvent(eventId, key, principal.getName()); + var optionalEvent = eventManager.getOptionalByName(eventShortName, principal.getName()); + + if(optionalEvent.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + var event = optionalEvent.get(); + String singleConfigForEvent = configurationManager.getSingleConfigForEvent(event.getId(), key, principal.getName()); if(singleConfigForEvent == null) { return ResponseEntity.noContent().build(); } return ResponseEntity.ok(singleConfigForEvent); } + @GetMapping("/organizations/{organizationId}/single/{key}") + public ResponseEntity getSingleConfigForOrganization(@PathVariable("organizationId") int organizationId, + @PathVariable("key") String key, + Principal principal) { + + String config = configurationManager.getSingleConfigForOrganization(organizationId, key, principal.getName()); + if(config == null) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(config); + } + @PostMapping(value = "/organizations/{organizationId}/events/{eventId}/update") public boolean updateEventConfiguration(@PathVariable("organizationId") int organizationId, @PathVariable("eventId") int eventId, @RequestBody Map> input, Principal principal) { diff --git a/src/main/java/alfio/controller/api/admin/EmailMessageApiController.java b/src/main/java/alfio/controller/api/admin/EmailMessageApiController.java index 489a7358ed..5e01d18c89 100644 --- a/src/main/java/alfio/controller/api/admin/EmailMessageApiController.java +++ b/src/main/java/alfio/controller/api/admin/EmailMessageApiController.java @@ -17,16 +17,16 @@ package alfio.controller.api.admin; import alfio.controller.api.support.PageAndContent; -import alfio.manager.EventManager; import alfio.manager.NotificationManager; +import alfio.manager.PurchaseContextManager; import alfio.model.EmailMessage; -import alfio.model.Event; import alfio.model.LightweightMailMessage; +import alfio.model.PurchaseContext; import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import lombok.experimental.Delegate; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.security.Principal; @@ -36,36 +36,35 @@ import java.util.Optional; import java.util.stream.Collectors; +@RequiredArgsConstructor @RestController -@RequestMapping("/admin/api/events/{eventName}/email") +@RequestMapping("/admin/api/{purchaseContextType}/{publicIdentifier}/email") public class EmailMessageApiController { private final NotificationManager notificationManager; - private final EventManager eventManager; - - @Autowired - public EmailMessageApiController(NotificationManager notificationManager, EventManager eventManager) { - this.notificationManager = notificationManager; - this.eventManager = eventManager; - } + private final PurchaseContextManager purchaseContextManager; @GetMapping("/") - public PageAndContent> loadEmailMessages(@PathVariable("eventName") String eventName, - @RequestParam(value = "page", required = false) Integer page, - @RequestParam(value = "search", required = false) String search, - Principal principal) { - Event event = eventManager.getSingleEvent(eventName, principal.getName()); - ZoneId zoneId = event.getZoneId(); - Pair> found = notificationManager.loadAllMessagesForEvent(event.getId(), page, search); + public PageAndContent> loadEmailMessages(@PathVariable("purchaseContextType") PurchaseContext.PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "search", required = false) String search, + Principal principal) { + var purchaseContext = purchaseContextManager.findBy(purchaseContextType, publicIdentifier).orElseThrow(); + ZoneId zoneId = purchaseContext.getZoneId(); + Pair> found = notificationManager.loadAllMessagesForPurchaseContext(purchaseContext, page, search); return new PageAndContent<>(found.getRight().stream() .map(m -> new LightweightEmailMessage(m, zoneId, true)) .collect(Collectors.toList()), found.getLeft()); } @GetMapping("/{messageId}") - public LightweightEmailMessage loadEmailMessage(@PathVariable("eventName") String eventName, @PathVariable("messageId") int messageId, Principal principal) { - Event event = eventManager.getSingleEvent(eventName, principal.getName()); - return notificationManager.loadSingleMessageForEvent(event.getId(), messageId).map(m -> new LightweightEmailMessage(m, event.getZoneId(), false)).orElseThrow(IllegalArgumentException::new); + public LightweightEmailMessage loadEmailMessage(@PathVariable("purchaseContextType") PurchaseContext.PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, + @PathVariable("messageId") int messageId, + Principal principal) { + var purchaseContext = purchaseContextManager.findBy(purchaseContextType, publicIdentifier).orElseThrow(); + return notificationManager.loadSingleMessageForPurchaseContext(purchaseContext, messageId).map(m -> new LightweightEmailMessage(m, purchaseContext.getZoneId(), false)).orElseThrow(IllegalArgumentException::new); } @AllArgsConstructor diff --git a/src/main/java/alfio/controller/api/admin/SubscriptionApiController.java b/src/main/java/alfio/controller/api/admin/SubscriptionApiController.java new file mode 100644 index 0000000000..daebf66988 --- /dev/null +++ b/src/main/java/alfio/controller/api/admin/SubscriptionApiController.java @@ -0,0 +1,112 @@ +/** + * 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.admin; + +import alfio.manager.SubscriptionManager; +import alfio.manager.user.UserManager; +import alfio.model.modification.SubscriptionDescriptorModification; +import alfio.model.subscription.EventSubscriptionLink; +import alfio.model.subscription.SubscriptionDescriptorWithStatistics; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/admin/api/organization/{organizationId}/subscription") +public class SubscriptionApiController { + + private final SubscriptionManager subscriptionManager; + private final UserManager userManager; + + public SubscriptionApiController(SubscriptionManager subscriptionManager, UserManager userManager) { + this.subscriptionManager = subscriptionManager; + this.userManager = userManager; + } + + @GetMapping("/list") + ResponseEntity> findAll(@PathVariable("organizationId") int organizationId, Principal principal) { + if (userManager.isOwnerOfOrganization(principal.getName(), organizationId)) { + return ResponseEntity.ok(subscriptionManager.loadSubscriptionsWithStatistics(organizationId)); + } else { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + } + + @GetMapping("/{subscriptionId}") + ResponseEntity getSingle(@PathVariable("organizationId") int organizationId, + @PathVariable("subscriptionId") UUID subscriptionId, + Principal principal) { + if(userManager.isOwnerOfOrganization(principal.getName(), organizationId)) { + return ResponseEntity.of(subscriptionManager.findOne(subscriptionId, organizationId).map(SubscriptionDescriptorModification::fromModel)); + } + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + @PostMapping("/") + ResponseEntity create(@PathVariable("organizationId") int organizationId, + @RequestBody SubscriptionDescriptorModification subscriptionDescriptor, + Principal principal) { + if (organizationId == subscriptionDescriptor.getOrganizationId() && userManager.isOwnerOfOrganization(principal.getName(), subscriptionDescriptor.getOrganizationId())) { + return ResponseEntity.of(subscriptionManager.createSubscriptionDescriptor(subscriptionDescriptor)); + } else { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @PostMapping("/{subscriptionId}") + ResponseEntity create(@PathVariable("organizationId") int organizationId, + @PathVariable("subscriptionId") UUID subscriptionId, + @RequestBody SubscriptionDescriptorModification subscriptionDescriptor, + Principal principal) { + if (organizationId == subscriptionDescriptor.getOrganizationId() + && userManager.isOwnerOfOrganization(principal.getName(), subscriptionDescriptor.getOrganizationId()) + && subscriptionId.equals(subscriptionDescriptor.getId()) + ) { + return ResponseEntity.of(subscriptionManager.updateSubscriptionDescriptor(subscriptionDescriptor)); + } else { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @PatchMapping("/{subscriptionId}/is-public") + ResponseEntity setPublicState(@PathVariable("organizationId") int organizationId, + @PathVariable("subscriptionId") UUID subscriptionId, + @RequestParam("status") boolean status, + Principal principal) { + if (userManager.isOwnerOfOrganization(principal.getName(), organizationId)) { + return ResponseEntity.ok(subscriptionManager.setPublicStatus(subscriptionId, organizationId, status)); + } else { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @GetMapping("/{subscriptionId}/events") + ResponseEntity> getLinkedEvents(@PathVariable("organizationId") int organizationId, + @PathVariable("subscriptionId") UUID subscriptionId, + Principal principal) { + if(userManager.isOwnerOfOrganization(principal.getName(), organizationId)) { + return ResponseEntity.ok(subscriptionManager.getLinkedEvents(organizationId, subscriptionId)); + } else { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } +} diff --git a/src/main/java/alfio/controller/api/v2/model/ApiPurchaseContext.java b/src/main/java/alfio/controller/api/v2/model/ApiPurchaseContext.java new file mode 100644 index 0000000000..b3c8b88562 --- /dev/null +++ b/src/main/java/alfio/controller/api/v2/model/ApiPurchaseContext.java @@ -0,0 +1,74 @@ +/** + * 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.model; + +import alfio.controller.api.support.CurrencyDescriptor; +import alfio.model.PurchaseContext; +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.joda.money.CurrencyUnit; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public interface ApiPurchaseContext { + + EventWithAdditionalInfo.InvoicingConfiguration getInvoicingConfiguration(); + EventWithAdditionalInfo.AssignmentConfiguration getAssignmentConfiguration(); + AnalyticsConfiguration getAnalyticsConfiguration(); + EventWithAdditionalInfo.CaptchaConfiguration getCaptchaConfiguration(); + boolean isVatIncluded(); + boolean isFree(); + String getCurrency(); + String getVat(); + + default List getContentLanguages() { + return purchaseContext().getContentLanguages() + .stream() + .map(cl -> new Language(cl.getLocale().getLanguage(), cl.getDisplayLanguage())) + .collect(Collectors.toList()); + } + + default CurrencyDescriptor getCurrencyDescriptor() { + if(purchaseContext().isFreeOfCharge()) { + return null; + } + var currencyUnit = CurrencyUnit.of(getCurrency()); + return new CurrencyDescriptor(currencyUnit.getCode(), currencyUnit.toCurrency().getDisplayName(), currencyUnit.getSymbol(), currencyUnit.getDecimalPlaces()); + } + + String getPrivacyPolicyUrl(); + + String getTermsAndConditionsUrl(); + + String getFileBlobId(); + + Map getTitle(); + Map getDescription(); + + String getBankAccount(); + List getBankAccountOwner(); + + + String getOrganizationEmail(); + String getOrganizationName(); + + @JsonIgnore + PurchaseContext purchaseContext(); + + boolean isCanApplySubscriptions(); +} diff --git a/src/main/java/alfio/controller/api/v2/model/BasicEventInfo.java b/src/main/java/alfio/controller/api/v2/model/BasicEventInfo.java index 5afc95c24e..1f74b5822c 100644 --- a/src/main/java/alfio/controller/api/v2/model/BasicEventInfo.java +++ b/src/main/java/alfio/controller/api/v2/model/BasicEventInfo.java @@ -28,7 +28,7 @@ public class BasicEventInfo implements DateValidity { private final String shortName; private final String fileBlobId; - private final String displayName; + private final Map title; private final EventFormat format; private final String location; diff --git a/src/main/java/alfio/controller/api/v2/model/BasicSubscriptionDescriptorInfo.java b/src/main/java/alfio/controller/api/v2/model/BasicSubscriptionDescriptorInfo.java new file mode 100644 index 0000000000..0cc8c227d6 --- /dev/null +++ b/src/main/java/alfio/controller/api/v2/model/BasicSubscriptionDescriptorInfo.java @@ -0,0 +1,62 @@ +/** + * 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.model; + +import alfio.controller.api.support.CurrencyDescriptor; +import alfio.model.subscription.SubscriptionDescriptor.SubscriptionTimeUnit; +import alfio.model.subscription.SubscriptionDescriptor.SubscriptionUsageType; +import alfio.model.subscription.SubscriptionDescriptor.SubscriptionValidityType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.ZoneId; +import java.util.Map; +import java.util.UUID; + +@Getter +@AllArgsConstructor +public class BasicSubscriptionDescriptorInfo { + private final UUID id; + private final String fileBlobId; + private final Map title; + private final Map description; + + // + private final DatesWithTimeZoneOffset salePeriod; + private final SubscriptionValidityType validityType; + private final SubscriptionUsageType usageType; + private final ZoneId timeZone; + private final SubscriptionTimeUnit validityTimeUnit; + private final Integer validityUnits; + private final Integer maxEntries; + + private final String organizationEmail; + private final String organizationName; + + private final String formattedPrice; + private final String currency; + private final CurrencyDescriptor currencyDescriptor; + private final BigDecimal vat; + private final boolean vatIncluded; + + private final Map formattedOnSaleFrom; + private final Map formattedOnSaleTo; + + private final Map formattedValidFrom; + private final Map formattedValidTo; +} diff --git a/src/main/java/alfio/controller/api/v2/model/DatesWithTimeZoneOffset.java b/src/main/java/alfio/controller/api/v2/model/DatesWithTimeZoneOffset.java index cbdba57cf3..0be0a94051 100644 --- a/src/main/java/alfio/controller/api/v2/model/DatesWithTimeZoneOffset.java +++ b/src/main/java/alfio/controller/api/v2/model/DatesWithTimeZoneOffset.java @@ -21,6 +21,8 @@ import java.time.ZonedDateTime; +import static java.time.temporal.ChronoField.OFFSET_SECONDS; + @Data public class DatesWithTimeZoneOffset { private final long startDateTime; @@ -33,8 +35,22 @@ public static DatesWithTimeZoneOffset fromEvent(Event event) { event.getBeginTimeZoneOffset(), toEpochMilli(event.getEnd()), event.getEndTimeZoneOffset()); } + public static DatesWithTimeZoneOffset fromDates(ZonedDateTime start, ZonedDateTime end) { + return new DatesWithTimeZoneOffset(toEpochMilli(start), getOffset(start), toEpochMilli(end), getOffset(end)); + } + private static long toEpochMilli(ZonedDateTime in) { - return in.toInstant().toEpochMilli(); + if(in != null) { + return in.toInstant().toEpochMilli(); + } + return 0; + } + + private static int getOffset(ZonedDateTime in) { + if(in != null) { + return in.getOffset().get(OFFSET_SECONDS); + } + return 0; } } diff --git a/src/main/java/alfio/controller/api/v2/model/EventWithAdditionalInfo.java b/src/main/java/alfio/controller/api/v2/model/EventWithAdditionalInfo.java index 357f757baf..e64825fecc 100644 --- a/src/main/java/alfio/controller/api/v2/model/EventWithAdditionalInfo.java +++ b/src/main/java/alfio/controller/api/v2/model/EventWithAdditionalInfo.java @@ -16,20 +16,19 @@ */ package alfio.controller.api.v2.model; -import alfio.controller.api.support.CurrencyDescriptor; import alfio.model.Event; import alfio.model.Event.EventFormat; +import alfio.model.PurchaseContext; import alfio.model.user.Organization; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.AllArgsConstructor; import lombok.Getter; -import org.joda.money.CurrencyUnit; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; @AllArgsConstructor -public class EventWithAdditionalInfo implements DateValidity { +public class EventWithAdditionalInfo implements DateValidity, ApiPurchaseContext { private final Event event; private final String mapUrl; private final Organization.OrganizationContact organization; @@ -71,6 +70,8 @@ public class EventWithAdditionalInfo implements DateValidity { private final String customCss; + private final boolean canApplySubscriptions; + public String getShortName() { return event.getShortName(); } @@ -91,29 +92,16 @@ public Integer getAvailableTicketsCount() { return availableTicketsCount; } - public CurrencyDescriptor getCurrencyDescriptor() { - if(event.isFreeOfCharge()) { - return null; - } - var currencyUnit = CurrencyUnit.of(event.getCurrency()); - return new CurrencyDescriptor(currencyUnit.getCode(), currencyUnit.toCurrency().getDisplayName(), currencyUnit.getSymbol(), currencyUnit.getDecimalPlaces()); - } - - public List getContentLanguages() { - return event.getContentLanguages() - .stream() - .map(cl -> new Language(cl.getLocale().getLanguage(), cl.getDisplayLanguage())) - .collect(Collectors.toList()); - } - public String getMapUrl() { return mapUrl; } + @Override public String getOrganizationName() { return organization.getName(); } + @Override public String getOrganizationEmail() { return organization.getEmail(); } @@ -122,64 +110,79 @@ public String getLocation() { return event.getLocation(); } + @Override public Map getDescription() { return description; } + @Override public String getPrivacyPolicyUrl() { return event.getPrivacyPolicyLinkOrNull(); } + @Override public String getTermsAndConditionsUrl() { return event.getTermsAndConditionsUrl(); } + @Override public String getCurrency() { return event.getCurrency(); } + @Override public boolean isVatIncluded() { return event.isVatIncluded(); } + @Override public String getVat() { return event.getVat().toString(); } + @Override public boolean isFree() { return event.getFree(); } + @Override public String getBankAccount() { return bankAccount; } + @Override public List getBankAccountOwner() { return bankAccountOwner; } // date related fields + @Override public boolean isSameDay() { return event.getSameDay(); } + @Override public Map getFormattedBeginDate() { return formattedBeginDate; } + @Override public Map getFormattedBeginTime() { return formattedBeginTime; } + @Override public Map getFormattedEndDate() { return formattedEndDate; } + @Override public Map getFormattedEndTime() { return formattedEndTime; } + @Override public String getTimeZone() { return event.getTimeZone(); } @@ -192,14 +195,17 @@ public DatesWithTimeZoneOffset getDatesWithOffset() { // + @Override public InvoicingConfiguration getInvoicingConfiguration() { return invoicingConfiguration; } + @Override public CaptchaConfiguration getCaptchaConfiguration() { return captchaConfiguration; } + @Override public AssignmentConfiguration getAssignmentConfiguration() { return assignmentConfiguration; } @@ -208,6 +214,7 @@ public PromotionsConfiguration getPromotionsConfiguration() { return promotionsConfiguration; } + @Override public AnalyticsConfiguration getAnalyticsConfiguration() { return analyticsConfiguration; } @@ -224,6 +231,11 @@ public String getCustomCss() { return customCss; } + @Override + public Map getTitle() { + return event.getTitle(); + } + @AllArgsConstructor @Getter public static class InvoicingConfiguration { @@ -259,4 +271,14 @@ public static class PromotionsConfiguration { private final boolean usePartnerCode; } + @JsonIgnore + @Override + public PurchaseContext purchaseContext() { + return event; + } + + @Override + public boolean isCanApplySubscriptions() { + return canApplySubscriptions; + } } diff --git a/src/main/java/alfio/controller/api/v2/model/ReservationInfo.java b/src/main/java/alfio/controller/api/v2/model/ReservationInfo.java index d54eea7aff..0d02957873 100644 --- a/src/main/java/alfio/controller/api/v2/model/ReservationInfo.java +++ b/src/main/java/alfio/controller/api/v2/model/ReservationInfo.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; @AllArgsConstructor @@ -73,6 +74,8 @@ public class ReservationInfo { private final Map activePaymentMethods; + private final List subscriptionInfos; + @AllArgsConstructor @Getter @@ -224,4 +227,11 @@ public static class ReservationInfoOrderSummaryRow { private final String subTotal; private final SummaryType type; } + + @AllArgsConstructor + @Getter + public static class SubscriptionInfo { + private final UUID id; + private final String pin; + } } diff --git a/src/main/java/alfio/controller/api/v2/model/SubscriptionDescriptorWithAdditionalInfo.java b/src/main/java/alfio/controller/api/v2/model/SubscriptionDescriptorWithAdditionalInfo.java new file mode 100644 index 0000000000..8eda816024 --- /dev/null +++ b/src/main/java/alfio/controller/api/v2/model/SubscriptionDescriptorWithAdditionalInfo.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.controller.api.v2.model; + +import alfio.controller.support.Formatters; +import alfio.model.PriceContainer; +import alfio.model.PurchaseContext; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.util.MonetaryUtil; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; + +import java.util.List; +import java.util.Map; + +@AllArgsConstructor +public class SubscriptionDescriptorWithAdditionalInfo implements ApiPurchaseContext { + private final SubscriptionDescriptor subscriptionDescriptor; + private final EventWithAdditionalInfo.InvoicingConfiguration invoicingConfiguration; + private final AnalyticsConfiguration analyticsConfiguration; + private final EventWithAdditionalInfo.CaptchaConfiguration captchaConfiguration; + + //payment related information + private final String bankAccount; + private final List bankAccountOwner; + // + + private final String organizationEmail; + private final String organizationName; + + private final DatesWithTimeZoneOffset salePeriod; + private final Map formattedOnSaleFrom; + private final Map formattedOnSaleTo; + private final String timeZone; + private final Map formattedValidFrom; + private final Map formattedValidTo; + + @Override + public EventWithAdditionalInfo.InvoicingConfiguration getInvoicingConfiguration() { + return invoicingConfiguration; + } + + @Override + public EventWithAdditionalInfo.AssignmentConfiguration getAssignmentConfiguration() { + return new EventWithAdditionalInfo.AssignmentConfiguration(false, false, false); + } + + @Override + public AnalyticsConfiguration getAnalyticsConfiguration() { + return analyticsConfiguration; + } + + @Override + public boolean isFree() { + return subscriptionDescriptor.isFreeOfCharge(); + } + + @Override + public String getCurrency() { + return subscriptionDescriptor.getCurrency(); + } + + public String getVat() { + return subscriptionDescriptor.getVat().toString(); + } + + @Override + @JsonIgnore + public PurchaseContext purchaseContext() { + return subscriptionDescriptor; + } + + @Override + public EventWithAdditionalInfo.CaptchaConfiguration getCaptchaConfiguration() { + return captchaConfiguration; + } + + @Override + public boolean isVatIncluded() { + return subscriptionDescriptor.getVatStatus() == PriceContainer.VatStatus.INCLUDED; + } + + @Override + public String getPrivacyPolicyUrl() { + return purchaseContext().getPrivacyPolicyLinkOrNull(); + } + + @Override + public String getTermsAndConditionsUrl() { + return purchaseContext().getTermsAndConditionsUrl(); + } + + @Override + public String getFileBlobId() { + return subscriptionDescriptor.getFileBlobId(); + } + + @Override + public Map getDescription() { + return Formatters.applyCommonMark(subscriptionDescriptor.getDescription()); + } + + public Map getTitle() { + return subscriptionDescriptor.getTitle(); + } + + @Override + public String getBankAccount() { + return bankAccount; + } + + @Override + public List getBankAccountOwner() { + return bankAccountOwner; + } + + @Override + public String getOrganizationEmail() { + return organizationEmail; + } + + @Override + public String getOrganizationName() { + return organizationName; + } + + public String getFormattedPrice() { + return MonetaryUtil.formatCents(subscriptionDescriptor.getPrice(), subscriptionDescriptor.getCurrency()); + } + + public DatesWithTimeZoneOffset getSalePeriod() { + return salePeriod; + } + + public Map getFormattedOnSaleFrom() { + return formattedOnSaleFrom; + } + + public Map getFormattedOnSaleTo() { + return formattedOnSaleTo; + } + + public String getTimeZone() { + return timeZone; + } + + public SubscriptionDescriptor.SubscriptionValidityType getValidityType() { + return subscriptionDescriptor.getValidityType(); + } + + public SubscriptionDescriptor.SubscriptionUsageType getUsageType() { + return subscriptionDescriptor.getUsageType(); + } + + public SubscriptionDescriptor.SubscriptionTimeUnit getValidityTimeUnit() { + return subscriptionDescriptor.getValidityTimeUnit(); + } + + public Integer getValidityUnits() { + return subscriptionDescriptor.getValidityUnits(); + } + + public Map getFormattedValidFrom() { + return formattedValidFrom; + } + + public Map getFormattedValidTo() { + return formattedValidTo; + } + + public Integer getMaxEntries() { + return subscriptionDescriptor.getMaxEntries() > 0 ? subscriptionDescriptor.getMaxEntries() : null; + } + + @Override + public boolean isCanApplySubscriptions() { + return false;//cannot buy a subscription with another subscription + } +} diff --git a/src/main/java/alfio/controller/api/v2/user/EventApiV2Controller.java b/src/main/java/alfio/controller/api/v2/user/EventApiV2Controller.java index 8fd3ca9b02..ffaa920385 100644 --- a/src/main/java/alfio/controller/api/v2/user/EventApiV2Controller.java +++ b/src/main/java/alfio/controller/api/v2/user/EventApiV2Controller.java @@ -23,6 +23,7 @@ import alfio.controller.api.v2.user.support.EventLoader; import alfio.controller.decorator.SaleableAdditionalService; import alfio.controller.decorator.SaleableTicketCategory; +import alfio.controller.form.EventSearchOptions; import alfio.controller.form.ReservationForm; import alfio.controller.form.WaitingQueueSubscriptionForm; import alfio.controller.support.Formatters; @@ -30,7 +31,6 @@ import alfio.manager.i18n.I18nManager; import alfio.manager.i18n.MessageSourceManager; import alfio.manager.support.response.ValidatedResponse; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.modification.TicketReservationModification; @@ -95,16 +95,16 @@ public class EventApiV2Controller { @GetMapping("events") - public ResponseEntity> listEvents() { + public ResponseEntity> listEvents(EventSearchOptions eventSearchOptions) { var contentLanguages = i18nManager.getAvailableLanguages(); - var events = eventManager.getPublishedEvents() + var events = eventManager.getPublishedEvents(eventSearchOptions) .stream() .map(e -> { - var messageSource = messageSourceManager.getMessageSourceForEvent(e); + var messageSource = messageSourceManager.getMessageSourceFor(e); var formattedDates = Formatters.getFormattedDates(e, messageSource, contentLanguages); - return new BasicEventInfo(e.getShortName(), e.getFileBlobId(), e.getDisplayName(), e.getFormat(), e.getLocation(), + return new BasicEventInfo(e.getShortName(), e.getFileBlobId(), e.getTitle(), e.getFormat(), e.getLocation(), e.getTimeZone(), DatesWithTimeZoneOffset.fromEvent(e), e.getSameDay(), formattedDates.beginDate, formattedDates.beginTime, formattedDates.endDate, formattedDates.endTime); }) @@ -142,9 +142,9 @@ public ResponseEntity getTicketCategories(@PathVariable("eventN // return eventRepository.findOptionalByShortName(eventName).filter(e -> e.getStatus() != Event.Status.DISABLED).map(event -> { - var configurations = configurationManager.getFor(List.of(DISPLAY_TICKETS_LEFT_INDICATOR, MAX_AMOUNT_OF_TICKETS_BY_RESERVATION, DISPLAY_EXPIRED_CATEGORIES), ConfigurationLevel.event(event)); + var configurations = configurationManager.getFor(List.of(DISPLAY_TICKETS_LEFT_INDICATOR, MAX_AMOUNT_OF_TICKETS_BY_RESERVATION, DISPLAY_EXPIRED_CATEGORIES), event.getConfigurationLevel()); var ticketCategoryLevelConfiguration = configurationManager.getAllCategoriesAndValueWith(event, MAX_AMOUNT_OF_TICKETS_BY_RESERVATION); - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); var appliedPromoCode = promoCodeRequestManager.checkCode(event, code); @@ -312,7 +312,7 @@ public ResponseEntity> reserveTickets(@PathVariable("e Optional promoCodeDiscount = codeCheck.map(ValidatedResponse::getValue).flatMap(Pair::getRight).map(PromoCodeDiscount::getPromoCode); var configurationValues = configurationManager.getFor(List.of( ENABLE_CAPTCHA_FOR_TICKET_SELECTION, - RECAPTCHA_API_KEY), ConfigurationLevel.event(event)); + RECAPTCHA_API_KEY), event.getConfigurationLevel()); if (isCaptchaInvalid(reservation.getCaptcha(), request.getRequest(), configurationValues)) { bindingResult.reject(ErrorsCode.STEP_2_CAPTCHA_VALIDATION_FAILED); @@ -445,7 +445,7 @@ private boolean isCaptchaInvalid(String recaptchaResponse, HttpServletRequest re private Map formatDynamicCodeMessage(Event event, PromoCodeDiscount promoCodeDiscount) { Validate.isTrue(promoCodeDiscount != null && promoCodeDiscount.getDiscountType() != PromoCodeDiscount.DiscountType.NONE); - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); Map res = new HashMap<>(); String code; String amount; 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 270e79d70e..c2592845f7 100644 --- a/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java +++ b/src/main/java/alfio/controller/api/v2/user/ReservationApiV2Controller.java @@ -25,6 +25,7 @@ import alfio.controller.api.v2.user.support.BookingInfoTicketLoader; import alfio.controller.form.ContactAndTicketsForm; import alfio.controller.form.PaymentForm; +import alfio.controller.form.ReservationCodeForm; import alfio.controller.support.TemplateProcessor; import alfio.manager.*; import alfio.manager.i18n.MessageSourceManager; @@ -32,10 +33,11 @@ import alfio.manager.payment.StripeCreditCardManager; import alfio.manager.support.PaymentResult; import alfio.manager.support.response.ValidatedResponse; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.system.ReservationPriceCalculator; import alfio.model.*; +import alfio.model.PurchaseContext.PurchaseContextType; +import alfio.model.subscription.Subscription; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; import alfio.repository.*; @@ -50,6 +52,7 @@ import org.springframework.http.ResponseEntity; 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; @@ -96,74 +99,79 @@ public class ReservationApiV2Controller { private final AdditionalServiceItemRepository additionalServiceItemRepository; private final AdditionalServiceRepository additionalServiceRepository; private final BillingDocumentManager billingDocumentManager; + private final PurchaseContextManager purchaseContextManager; + private final SubscriptionRepository subscriptionRepository; /** * Note: now it will return for any states of the reservation. * - * @param eventName * @param reservationId * @return */ - @GetMapping("/event/{eventName}/reservation/{reservationId}") - public ResponseEntity getReservationInfo(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId) { + @GetMapping({"/reservation/{reservationId}", + "/event/{eventName}/reservation/{reservationId}" //<-deprecated + }) + public ResponseEntity getReservationInfo(@PathVariable("reservationId") String reservationId) { - Optional res = eventRepository.findOptionalByShortName(eventName).flatMap(event -> ticketReservationManager.findById(reservationId).flatMap(reservation -> { + Optional res = purchaseContextManager.findByReservationId(reservationId).flatMap(purchaseContext -> ticketReservationManager.findById(reservationId).flatMap(reservation -> { - var orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, event); + var orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, purchaseContext); var tickets = ticketReservationManager.findTicketsInReservation(reservationId); var ticketIds = tickets.stream().map(Ticket::getId).collect(Collectors.toSet()); - var descriptionsByTicketFieldId = ticketFieldRepository.findDescriptions(event.getShortName()) - .stream() - .collect(Collectors.groupingBy(TicketFieldDescription::getTicketFieldConfigurationId)); - - var valuesByTicketIds = ticketFieldRepository.findAllValuesByTicketIds(ticketIds) - .stream() - .collect(Collectors.groupingBy(TicketFieldValue::getTicketId)); // check if the user can cancel ticket boolean hasPaidSupplement = ticketReservationManager.hasPaidSupplements(reservationId); // - var ticketFieldsFilterer = bookingInfoTicketLoader.getTicketFieldsFilterer(reservationId, event); + var ticketsInfo = purchaseContext.event().map(event -> { + var valuesByTicketIds = ticketFieldRepository.findAllValuesByTicketIds(ticketIds) + .stream() + .collect(Collectors.groupingBy(TicketFieldValue::getTicketId)); + + var descriptionsByTicketFieldId = ticketFieldRepository.findDescriptions(event.getShortName()) + .stream() + .collect(Collectors.groupingBy(TicketFieldDescription::getTicketFieldConfigurationId)); + + var ticketFieldsFilterer = bookingInfoTicketLoader.getTicketFieldsFilterer(reservationId, event); + var ticketsByCategory = tickets.stream().collect(Collectors.groupingBy(Ticket::getCategoryId)); + //TODO: cleanup this transformation, we most likely don't need to fully load the ticket category + var ticketsInReservation = ticketsByCategory + .entrySet() + .stream() + .map(e -> { + var tc = eventManager.getTicketCategoryById(e.getKey(), event.getId()); + var ts = e.getValue().stream() + .map(t -> bookingInfoTicketLoader.toBookingInfoTicket(t, hasPaidSupplement, event, ticketFieldsFilterer, descriptionsByTicketFieldId, valuesByTicketIds, Map.of(), false)) + .collect(Collectors.toList()); + return new TicketsByTicketCategory(tc.getName(), tc.getTicketAccessType(), ts); + }) + .collect(Collectors.toList()); + return Pair.of(ticketsByCategory, ticketsInReservation); + }); + var ticketsByCategory = ticketsInfo.map(Pair::getLeft).orElse(Map.of()); + var ticketsInReservation = ticketsInfo.map(Pair::getRight).orElse(List.of()); - var ticketsByCategory = tickets.stream().collect(Collectors.groupingBy(Ticket::getCategoryId)); - //TODO: cleanup this transformation, we most likely don't need to fully load the ticket category - var ticketsInReservation = ticketsByCategory - .entrySet() - .stream() - .map(e -> { - var tc = eventManager.getTicketCategoryById(e.getKey(), event.getId()); - var ts = e.getValue().stream() - .map(t -> bookingInfoTicketLoader.toBookingInfoTicket(t, hasPaidSupplement, event, ticketFieldsFilterer, descriptionsByTicketFieldId, valuesByTicketIds, Map.of(), false)) - .collect(Collectors.toList()); - return new TicketsByTicketCategory(tc.getName(), tc.getTicketAccessType(), ts); - }) - .collect(Collectors.toList()); - // var additionalInfo = ticketReservationRepository.getAdditionalInfo(reservationId); - var italianInvoicing = additionalInfo.getInvoicingAdditionalInfo().getItalianEInvoicing() == null ? - new TicketReservationInvoicingAdditionalInfo.ItalianEInvoicing(null, null, null, null) : - additionalInfo.getInvoicingAdditionalInfo().getItalianEInvoicing(); - // - - - var shortReservationId = ticketReservationManager.getShortReservationID(event, reservation); + var shortReservationId = ticketReservationManager.getShortReservationID(purchaseContext, reservation); - var formattedExpirationDate = reservation.getValidity() != null ? formatDateForLocales(event, ZonedDateTime.ofInstant(reservation.getValidity().toInstant(), event.getZoneId()), "datetime.pattern") : null; + var formattedExpirationDate = reservation.getValidity() != null ? formatDateForLocales(purchaseContext, ZonedDateTime.ofInstant(reservation.getValidity().toInstant(), purchaseContext.getZoneId()), "datetime.pattern") : null; var paymentToken = paymentManager.getPaymentToken(reservationId); boolean tokenAcquired = paymentToken.isPresent(); PaymentProxy selectedPaymentProxy = paymentToken.map(PaymentToken::getPaymentProvider).orElse(null); // - var containsCategoriesLinkedToGroups = ticketReservationManager.containsCategoriesLinkedToGroups(reservationId, event.getId()); + var containsCategoriesLinkedToGroups = purchaseContext.event().map(event -> ticketReservationManager.containsCategoriesLinkedToGroups(reservationId, event.getId())).orElse(false); // + List subscriptionInfos = null; + if (purchaseContext.getType() == PurchaseContextType.subscription) { + subscriptionInfos = subscriptionRepository.findSubscriptionsByReservationId(reservationId).stream().map(s -> new ReservationInfo.SubscriptionInfo(s.getId(), s.getPin())).collect(Collectors.toList()); + } return Optional.of(new ReservationInfo(reservation.getId(), shortReservationId, reservation.getFirstName(), reservation.getLastName(), reservation.getEmail(), @@ -185,7 +193,8 @@ public ResponseEntity getReservationInfo(@PathVariable("eventNa additionalInfo.getBillingDetails(), // containsCategoriesLinkedToGroups, - getActivePaymentMethods(event, ticketsByCategory.keySet(), orderSummary, reservationId) + getActivePaymentMethods(purchaseContext, ticketsByCategory.keySet(), orderSummary, reservationId), + subscriptionInfos )); })); @@ -193,71 +202,69 @@ public ResponseEntity getReservationInfo(@PathVariable("eventNa return res.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); } - private Map getActivePaymentMethods(Event event, + private Map getActivePaymentMethods(PurchaseContext purchaseContext, Collection categoryIds, OrderSummary orderSummary, String reservationId) { - if(!event.isFreeOfCharge()) { - var blacklistedMethodsForReservation = configurationManager.getBlacklistedMethodsForReservation(event, categoryIds); - return paymentManager.getPaymentMethods(event, new TransactionRequest(orderSummary.getOriginalTotalPrice(), ticketReservationRepository.getBillingDetailsForReservation(reservationId))) + if(!purchaseContext.isFreeOfCharge()) { + var blacklistedMethodsForReservation = configurationManager.getBlacklistedMethodsForReservation(purchaseContext, categoryIds); + return paymentManager.getPaymentMethods(purchaseContext, new TransactionRequest(orderSummary.getOriginalTotalPrice(), ticketReservationRepository.getBillingDetailsForReservation(reservationId))) .stream() .filter(p -> !blacklistedMethodsForReservation.contains(p.getPaymentMethod())) - .filter(p -> TicketReservationManager.isValidPaymentMethod(p, event, configurationManager)) - .collect(toMap(PaymentManager.PaymentMethodDTO::getPaymentMethod, pm -> new PaymentProxyWithParameters(pm.getPaymentProxy(), paymentManager.loadModelOptionsFor(List.of(pm.getPaymentProxy()), event)))); + .filter(p -> TicketReservationManager.isValidPaymentMethod(p, purchaseContext, configurationManager)) + .collect(toMap(PaymentManager.PaymentMethodDTO::getPaymentMethod, pm -> new PaymentProxyWithParameters(pm.getPaymentProxy(), paymentManager.loadModelOptionsFor(List.of(pm.getPaymentProxy()), purchaseContext)))); } else { return Map.of(); } } - @GetMapping("/event/{eventName}/reservation/{reservationId}/status") - public ResponseEntity getReservationStatus(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId) { - - Optional res = Optional.empty(); - if (eventRepository.existsByShortName(eventName)) { - res = ticketReservationRepository.findOptionalStatusAndValidationById(reservationId) - .map(status -> new ReservationStatusInfo(status.getStatus(), Boolean.TRUE.equals(status.getValidated()))); - } + @GetMapping({ + "/reservation/{reservationId}/status", + "/event/{eventName}/reservation/{reservationId}/status" //<- deprecated + }) + public ResponseEntity getReservationStatus(@PathVariable("reservationId") String reservationId) { + Optional res = ticketReservationRepository.findOptionalStatusAndValidationById(reservationId) + .map(status -> new ReservationStatusInfo(status.getStatus(), Boolean.TRUE.equals(status.getValidated()))); return res.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); } - @DeleteMapping("/event/{eventName}/reservation/{reservationId}") - public ResponseEntity cancelPendingReservation(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId) { - - getReservationWithPendingStatus(eventName, reservationId) - .ifPresent(er -> ticketReservationManager.cancelPendingReservation(reservationId, false, null)); + @DeleteMapping({ + "/reservation/{reservationId}", + "/event/{eventName}/reservation/{reservationId}" //<- deprecated + }) + public ResponseEntity cancelPendingReservation(@PathVariable("reservationId") String reservationId) { + getReservationWithPendingStatus(reservationId).ifPresent(er -> ticketReservationManager.cancelPendingReservation(reservationId, false, null)); return ResponseEntity.ok(true); } - @PostMapping("/event/{eventName}/reservation/{reservationId}/back-to-booking") - public ResponseEntity backToBooking(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId) { - - getReservationWithPendingStatus(eventName, reservationId) - .ifPresent(er -> ticketReservationRepository.updateValidationStatus(reservationId, false)); - - + @PostMapping({ + "/reservation/{reservationId}/back-to-booking", + "/event/{eventName}/reservation/{reservationId}/back-to-booking" //<- deprecated + }) + public ResponseEntity backToBooking(@PathVariable("reservationId") String reservationId) { + getReservationWithPendingStatus(reservationId).ifPresent(er -> ticketReservationRepository.updateValidationStatus(reservationId, false)); return ResponseEntity.ok(true); } - @PostMapping("/event/{eventName}/reservation/{reservationId}") - public ResponseEntity> confirmOverview(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + @PostMapping({ + "/reservation/{reservationId}", + "/event/{eventName}/reservation/{reservationId}"// <- deprecated + }) + public ResponseEntity> confirmOverview(@PathVariable("reservationId") String reservationId, @RequestParam("lang") String lang, @RequestBody PaymentForm paymentForm, BindingResult bindingResult, HttpServletRequest request) { - return getReservation(eventName, reservationId).map(er -> { + return getReservation(reservationId).map(er -> { var event = er.getLeft(); - var ticketReservation = er.getRight(); + var reservation = er.getRight(); var locale = LocaleUtil.forLanguageTag(lang, event); - if (!ticketReservation.getValidity().after(new Date())) { + if (!reservation.getValidity().after(new Date())) { bindingResult.reject(ErrorsCode.STEP_2_ORDER_EXPIRED); } @@ -275,14 +282,14 @@ public ResponseEntity> confirmOvervi } if(!bindingResult.hasErrors()) { - extensionManager.handleReservationValidation(event, ticketReservation, paymentForm, bindingResult); + extensionManager.handleReservationValidation(event, reservation, paymentForm, bindingResult); } if (bindingResult.hasErrors()) { return buildReservationPaymentStatus(bindingResult); } - CustomerName customerName = new CustomerName(ticketReservation.getFullName(), ticketReservation.getFirstName(), ticketReservation.getLastName(), event.mustUseFirstAndLastName()); + CustomerName customerName = new CustomerName(reservation.getFullName(), reservation.getFirstName(), reservation.getLastName(), true); OrderSummary orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, event); @@ -292,9 +299,9 @@ public ResponseEntity> confirmOvervi new PaymentContext(event, reservationId)); } PaymentSpecification spec = new PaymentSpecification(reservationId, paymentToken, reservationCost.getPriceWithVAT(), - event, ticketReservation.getEmail(), customerName, ticketReservation.getBillingAddress(), ticketReservation.getCustomerReference(), - locale, ticketReservation.isInvoiceRequested(), !ticketReservation.isDirectAssignmentRequested(), - orderSummary, ticketReservation.getVatCountryCode(), ticketReservation.getVatNr(), ticketReservation.getVatStatus(), + event, reservation.getEmail(), customerName, reservation.getBillingAddress(), reservation.getCustomerReference(), + locale, reservation.isInvoiceRequested(), !reservation.isDirectAssignmentRequested(), + orderSummary, reservation.getVatCountryCode(), reservation.getVatNr(), reservation.getVatStatus(), Boolean.TRUE.equals(paymentForm.getTermAndConditionsAccepted()), Boolean.TRUE.equals(paymentForm.getPrivacyPolicyAccepted())); final PaymentResult status = ticketReservationManager.performPayment(spec, reservationCost, paymentForm.getPaymentProxy(), paymentForm.getSelectedPaymentMethod()); @@ -308,7 +315,7 @@ public ResponseEntity> confirmOvervi if (!status.isSuccessful()) { String errorMessageCode = status.getErrorCode().orElse(StripeCreditCardManager.STRIPE_UNEXPECTED); MessageSourceResolvable message = new DefaultMessageSourceResolvable(new String[]{errorMessageCode, StripeCreditCardManager.STRIPE_UNEXPECTED}); - bindingResult.reject(ErrorsCode.STEP_2_PAYMENT_PROCESSING_ERROR, new Object[]{messageSourceManager.getMessageSourceForEvent(event).getMessage(message, locale)}, null); + bindingResult.reject(ErrorsCode.STEP_2_PAYMENT_PROCESSING_ERROR, new Object[]{messageSourceManager.getMessageSourceFor(event).getMessage(message, locale)}, null); return buildReservationPaymentStatus(bindingResult); } @@ -323,26 +330,30 @@ private static ResponseEntity> build return ResponseEntity.status(bindingResult.hasErrors() ? HttpStatus.UNPROCESSABLE_ENTITY : HttpStatus.OK).body(body); } - @PostMapping("/event/{eventName}/reservation/{reservationId}/validate-to-overview") - public ResponseEntity> validateToOverview(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + @PostMapping({ + "/reservation/{reservationId}/validate-to-overview", + "/event/{eventName}/reservation/{reservationId}/validate-to-overview" //<-deprecated + }) + public ResponseEntity> validateToOverview(@PathVariable("reservationId") String reservationId, @RequestParam("lang") String lang, @RequestBody ContactAndTicketsForm contactAndTicketsForm, BindingResult bindingResult) { - return getReservationWithPendingStatus(eventName, reservationId).map(er -> { - var event = er.getLeft(); + return getPurchaseContextAndReservationWithPendingStatus(reservationId).map(er -> { + var purchaseContext = er.getLeft(); var reservation = er.getRight(); - var locale = LocaleUtil.forLanguageTag(lang, event); - final TotalPrice reservationCost = ticketReservationManager.totalReservationCostWithVAT(reservation.withVatStatus(event.getVatStatus())).getLeft(); - boolean forceAssignment = configurationManager.getFor(FORCE_TICKET_OWNER_ASSIGNMENT_AT_RESERVATION, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + var locale = LocaleUtil.forLanguageTag(lang, purchaseContext); + final TotalPrice reservationCost = ticketReservationManager.totalReservationCostWithVAT(reservation.withVatStatus(purchaseContext.getVatStatus())).getLeft(); + boolean forceAssignment = configurationManager.getFor(FORCE_TICKET_OWNER_ASSIGNMENT_AT_RESERVATION, purchaseContext.getConfigurationLevel()).getValueAsBooleanOrDefault(); - if(forceAssignment || ticketReservationManager.containsCategoriesLinkedToGroups(reservationId, event.getId())) { - contactAndTicketsForm.setPostponeAssignment(false); - } + purchaseContext.event().ifPresent(event -> { + if (forceAssignment || ticketReservationManager.containsCategoriesLinkedToGroups(reservationId, event.getId())) { + contactAndTicketsForm.setPostponeAssignment(false); + } + }); - boolean invoiceOnly = configurationManager.isInvoiceOnly(event); + boolean invoiceOnly = configurationManager.isInvoiceOnly(purchaseContext); if(invoiceOnly && reservationCost.getPriceWithVAT() > 0) { //override, that's why we save it @@ -351,13 +362,13 @@ public ResponseEntity> validateToOverview(@PathVariab contactAndTicketsForm.setInvoiceRequested(false); } - CustomerName customerName = new CustomerName(contactAndTicketsForm.getFullName(), contactAndTicketsForm.getFirstName(), contactAndTicketsForm.getLastName(), event.mustUseFirstAndLastName(), false); + CustomerName customerName = new CustomerName(contactAndTicketsForm.getFullName(), contactAndTicketsForm.getFirstName(), contactAndTicketsForm.getLastName(), true, false); - ticketReservationRepository.resetVat(reservationId, contactAndTicketsForm.isInvoiceRequested(), event.getVatStatus(), + ticketReservationRepository.resetVat(reservationId, contactAndTicketsForm.isInvoiceRequested(), purchaseContext.getVatStatus(), reservation.getSrcPriceCts(), reservationCost.getPriceWithVAT(), reservationCost.getVAT(), Math.abs(reservationCost.getDiscount()), reservation.getCurrencyCode()); if(contactAndTicketsForm.isBusiness()) { - checkAndApplyVATRules(eventName, reservationId, contactAndTicketsForm, bindingResult, event); + checkAndApplyVATRules(purchaseContext, reservationId, contactAndTicketsForm, bindingResult); } //persist data @@ -367,28 +378,30 @@ public ResponseEntity> validateToOverview(@PathVariab contactAndTicketsForm.getCustomerReference(), contactAndTicketsForm.getVatNr(), contactAndTicketsForm.isInvoiceRequested(), contactAndTicketsForm.getAddCompanyBillingDetails(), contactAndTicketsForm.canSkipVatNrCheck(), false, locale); - boolean italyEInvoicing = configurationManager.getFor(ENABLE_ITALY_E_INVOICING, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + boolean italyEInvoicing = configurationManager.getFor(ENABLE_ITALY_E_INVOICING, purchaseContext.getConfigurationLevel()).getValueAsBooleanOrDefault(); if(italyEInvoicing) { - ticketReservationManager.updateReservationInvoicingAdditionalInformation(reservationId, event, + ticketReservationManager.updateReservationInvoicingAdditionalInformation(reservationId, purchaseContext, new TicketReservationInvoicingAdditionalInfo(getItalianInvoicingInfo(contactAndTicketsForm)) ); } - assignTickets(event.getShortName(), reservationId, contactAndTicketsForm, bindingResult, locale, true, true); + purchaseContext.event().ifPresent(event -> { + assignTickets(event.getShortName(), reservationId, contactAndTicketsForm, bindingResult, locale, true, true); + }); // Map formValidationParameters = Collections.singletonMap(ENABLE_ITALY_E_INVOICING, italyEInvoicing); - var ticketFieldFilterer = bookingInfoTicketLoader.getTicketFieldsFilterer(reservationId, event); + var ticketFieldFilterer = purchaseContext.event().map(event -> bookingInfoTicketLoader.getTicketFieldsFilterer(reservationId, event)); + // - contactAndTicketsForm.validate(bindingResult, event, - new SameCountryValidator(configurationManager, extensionManager, event, reservationId, vatChecker), + contactAndTicketsForm.validate(bindingResult, purchaseContext, new SameCountryValidator(configurationManager, extensionManager, purchaseContext, reservationId, vatChecker), formValidationParameters, ticketFieldFilterer); // if(!bindingResult.hasErrors()) { - extensionManager.handleReservationValidation(event, reservation, contactAndTicketsForm, bindingResult); + extensionManager.handleReservationValidation(purchaseContext, reservation, contactAndTicketsForm, bindingResult); } if(!bindingResult.hasErrors()) { @@ -423,17 +436,17 @@ private void assignTickets(String eventName, String reservationId, ContactAndTic } } - private void checkAndApplyVATRules(String eventName, String reservationId, ContactAndTicketsForm contactAndTicketsForm, BindingResult bindingResult, Event event) { + private void checkAndApplyVATRules(PurchaseContext purchaseContext, String reservationId, ContactAndTicketsForm contactAndTicketsForm, BindingResult bindingResult) { // VAT handling String country = contactAndTicketsForm.getVatCountryCode(); // validate VAT presence if EU mode is enabled - if (vatChecker.isReverseChargeEnabledFor(event) && (country == null || isEUCountry(country))) { + if (vatChecker.isReverseChargeEnabledFor(purchaseContext) && (country == null || isEUCountry(country))) { ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "vatNr", "error.emptyField"); } try { - Optional vatDetail = eventRepository.findOptionalByShortName(eventName) + Optional vatDetail = Optional.of(purchaseContext) .flatMap(e -> ticketReservationRepository.findOptionalReservationById(reservationId).map(r -> Pair.of(e, r))) .filter(e -> EnumSet.of(INCLUDED, NOT_INCLUDED).contains(e.getKey().getVatStatus())) .filter(e -> vatChecker.isReverseChargeEnabledFor(e.getKey())) @@ -446,16 +459,19 @@ private void checkAndApplyVATRules(String eventName, String reservationId, Conta } else { var reservation = ticketReservationManager.findById(reservationId).orElseThrow(); var currencyCode = reservation.getCurrencyCode(); - PriceContainer.VatStatus vatStatus = determineVatStatus(event.getVatStatus(), vatValidation.isVatExempt()); + PriceContainer.VatStatus vatStatus = determineVatStatus(purchaseContext.getVatStatus(), vatValidation.isVatExempt()); var discount = reservation.getPromoCodeDiscountId() != null ? promoCodeDiscountRepository.findById(reservation.getPromoCodeDiscountId()) : null; var additionalServiceItems = additionalServiceItemRepository.findByReservationUuid(reservationId); var tickets = ticketReservationManager.findTicketsInReservation(reservationId); - var calculator = new ReservationPriceCalculator(reservation.withVatStatus(vatStatus), discount, tickets, additionalServiceItems, additionalServiceRepository.loadAllForEvent(event.getId()), event); + var additionalServices = purchaseContext.event().map(event -> additionalServiceRepository.loadAllForEvent(event.getId())).orElse(List.of()); + var subscriptions = subscriptionRepository.findSubscriptionsByReservationId(reservationId); + var appliedSubscription = subscriptionRepository.findAppliedSubscriptionByReservationId(reservationId); + var calculator = new ReservationPriceCalculator(reservation.withVatStatus(vatStatus), discount, tickets, additionalServiceItems, additionalServices, purchaseContext, subscriptions, appliedSubscription); ticketReservationRepository.updateBillingData(vatStatus, reservation.getSrcPriceCts(), unitToCents(calculator.getFinalPrice(), currencyCode), unitToCents(calculator.getVAT(), currencyCode), unitToCents(calculator.getAppliedDiscount(), currencyCode), reservation.getCurrencyCode(), StringUtils.trimToNull(vatValidation.getVatNr()), country, contactAndTicketsForm.isInvoiceRequested(), reservationId); - vatChecker.logSuccessfulValidation(vatValidation, reservationId, event.getId()); + vatChecker.logSuccessfulValidation(vatValidation, reservationId, purchaseContext.event().map(Event::getId).orElse(null)); } }); } catch (IllegalStateException ise) {//vat checker failure @@ -463,35 +479,37 @@ private void checkAndApplyVATRules(String eventName, String reservationId, Conta } } - private Optional> getReservation(String eventName, String reservationId) { - return eventRepository.findOptionalByShortName(eventName) - .flatMap(event -> ticketReservationManager.findById(reservationId) - .flatMap(reservation -> Optional.of(Pair.of(event, reservation)))); + private Optional> getReservation(String reservationId) { + return purchaseContextManager.findByReservationId(reservationId) + .flatMap(purchaseContext -> ticketReservationManager.findById(reservationId) + .flatMap(reservation -> Optional.of(Pair.of(purchaseContext, reservation)))); } - private Optional> getReservationWithPendingStatus(String eventName, String reservationId) { - return eventRepository.findOptionalByShortName(eventName) + private Optional getReservationWithPendingStatus(String reservationId) { + return ticketReservationManager.findById(reservationId).filter(reservation -> reservation.getStatus() == TicketReservation.TicketReservationStatus.PENDING); + } + + private Optional> getPurchaseContextAndReservationWithPendingStatus(String reservationId) { + return purchaseContextManager.findByReservationId(reservationId) .flatMap(event -> ticketReservationManager.findById(reservationId) .filter(reservation -> reservation.getStatus() == TicketReservation.TicketReservationStatus.PENDING) .flatMap(reservation -> Optional.of(Pair.of(event, reservation)))); } - @PostMapping("/event/{eventName}/reservation/{reservationId}/re-send-email") - public ResponseEntity reSendReservationConfirmationEmail(@PathVariable("eventName") String eventName, + @PostMapping("/{purchaseContextType}/{publicIdentifier}/reservation/{reservationId}/re-send-email") + public ResponseEntity reSendReservationConfirmationEmail(@PathVariable("purchaseContextType") PurchaseContextType purchaseContextType, + @PathVariable("publicIdentifier") String publicIdentifier, @PathVariable("reservationId") String reservationId, @RequestParam("lang") String lang, Principal principal) { - - var res = eventRepository.findOptionalByShortName(eventName).map(event -> - ticketReservationManager.findById(reservationId).map(ticketReservation -> { - ticketReservationManager.sendConfirmationEmail(event, ticketReservation, LocaleUtil.forLanguageTag(lang, event), principal != null ? principal.getName() : null); - return true; - }).orElse(false) - ).orElse(false); - - return ResponseEntity.ok(res); + return ResponseEntity.of(purchaseContextManager.findBy(purchaseContextType, publicIdentifier) + .map(purchaseContext -> ticketReservationManager.findById(reservationId) + .map(ticketReservation -> { + ticketReservationManager.sendConfirmationEmail(purchaseContext, ticketReservation, LocaleUtil.forLanguageTag(lang, purchaseContext), principal != null ? principal.getName() : null); + return true; + }).orElse(false))); } @@ -530,8 +548,8 @@ private ResponseEntity handleReservationWith(String eventName, String rese ).orElse(notFound); } - private boolean canAccessReceiptOrInvoice(EventAndOrganizationId event, Authentication authentication) { - return configurationManager.canGenerateReceiptOrInvoiceToCustomer(event) || !isAnonymous(authentication); + private boolean canAccessReceiptOrInvoice(Configurable configurable, Authentication authentication) { + return configurationManager.canGenerateReceiptOrInvoiceToCustomer(configurable) || !isAnonymous(authentication); } @@ -562,9 +580,11 @@ private BiFunction> generatePdfFu //---------------- - @PostMapping("/event/{eventName}/reservation/{reservationId}/payment/{method}/init") - public ResponseEntity initTransaction(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + @PostMapping({ + "/reservation/{reservationId}/payment/{method}/init", + "/event/{eventName}/reservation/{reservationId}/payment/{method}/init" //<-deprecated + }) + public ResponseEntity initTransaction(@PathVariable("reservationId") String reservationId, @PathVariable("method") String paymentMethodStr, @RequestParam MultiValueMap allParams) { var paymentMethod = PaymentMethod.safeParse(paymentMethodStr); @@ -573,7 +593,7 @@ public ResponseEntity initTransaction(@PathVaria return ResponseEntity.badRequest().build(); } - Optional> responseEntity = getEventReservationPair(eventName, reservationId) + Optional> responseEntity = getEventReservationPair(reservationId) .map(pair -> { var event = pair.getLeft(); return ticketReservationManager.initTransaction(event, reservationId, paymentMethod, allParams) @@ -583,31 +603,40 @@ public ResponseEntity initTransaction(@PathVaria return responseEntity.orElseGet(() -> ResponseEntity.badRequest().build()); } - @DeleteMapping("/event/{eventName}/reservation/{reservationId}/payment/token") + @DeleteMapping({ + "/reservation/{reservationId}/payment/token", + "/event/{eventName}/reservation/{reservationId}/payment/token" //<-deprecated + }) public ResponseEntity removeToken(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId) { - var res = getEventReservationPair(eventName, reservationId).map(et -> paymentManager.removePaymentTokenReservation(et.getRight().getId())).orElse(false); + var res = getEventReservationPair(reservationId).map(et -> paymentManager.removePaymentTokenReservation(et.getRight().getId())).orElse(false); return ResponseEntity.ok(res); } - @DeleteMapping("/event/{eventName}/reservation/{reservationId}/payment") - public ResponseEntity deletePaymentAttempt(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId) { + @DeleteMapping({ + "/reservation/{reservationId}/payment", + "/event/{eventName}/reservation/{reservationId}/payment" //<-deprecated + }) + public ResponseEntity deletePaymentAttempt(@PathVariable("reservationId") String reservationId) { - var res = getEventReservationPair(eventName, reservationId).map(et -> ticketReservationManager.cancelPendingPayment(et.getRight().getId(), et.getLeft())).orElse(false); + var res = getEventReservationPair(reservationId).map(et -> ticketReservationManager.cancelPendingPayment(et.getRight().getId(), et.getLeft())).orElse(false); return ResponseEntity.ok(res); } - private Optional> getEventReservationPair(String eventName, String reservationId) { - return eventRepository.findOptionalByShortName(eventName) + //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("/event/{eventName}/reservation/{reservationId}/payment/{method}/status") - public ResponseEntity getTransactionStatus(@PathVariable("eventName") String eventName, + @GetMapping({ + "/reservation/{reservationId}/payment/{method}/status", + "/event/{eventName}/reservation/{reservationId}/payment/{method}/status" //<-deprecated + }) + public ResponseEntity getTransactionStatus( @PathVariable("reservationId") String reservationId, @PathVariable("method") String paymentMethodStr) { @@ -617,19 +646,83 @@ public ResponseEntity getTransactionStatus(@PathVariab return ResponseEntity.badRequest().build(); } - return getEventReservationPair(eventName, reservationId) + return getEventReservationPair(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()); } + @PostMapping("/reservation/{reservationId}/apply-code") + public ResponseEntity> applyCode(@PathVariable("reservationId") String reservationId, @RequestBody ReservationCodeForm reservationCodeForm, BindingResult bindingResult) { + boolean res; + switch (reservationCodeForm.getType()) { + case SUBSCRIPTION: + res = getEventReservationPair(reservationId).map(et -> { + var isUUID = reservationCodeForm.isCodeUUID(); + 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; + } + } + 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(subscriptionDescriptor, Optional.of(bindingResult)); + if (bindingResult.hasErrors()) { + return false; + } + return ticketReservationManager.applySubscriptionCode(et.getRight(), subscriptionId, reservationCodeForm.getAmount()); + }).orElse(false); + break; + default: throw new IllegalStateException(reservationCodeForm.getType() + " not supported"); + } + return ResponseEntity.ok(ValidatedResponse.toResponse(bindingResult, res)); + } + + @DeleteMapping("/reservation/{reservationId}/remove-code") + 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); + } + return ResponseEntity.ok(res); + } + - private Map formatDateForLocales(Event event, ZonedDateTime date, String formattingCode) { + private Map formatDateForLocales(PurchaseContext purchaseContext, ZonedDateTime date, String formattingCode) { - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); Map res = new HashMap<>(); - for (ContentLanguage cl : event.getContentLanguages()) { + for (ContentLanguage cl : purchaseContext.getContentLanguages()) { var formatter = messageSource.getMessage(formattingCode, null, cl.getLocale()); res.put(cl.getLocale().getLanguage(), DateTimeFormatter.ofPattern(formatter, cl.getLocale()).format(date)); } @@ -648,9 +741,9 @@ private static PriceContainer.VatStatus determineVatStatus(PriceContainer.VatSta } - private boolean isCaptchaInvalid(int cost, PaymentProxy paymentMethod, String recaptchaResponse, HttpServletRequest request, EventAndOrganizationId event) { + private boolean isCaptchaInvalid(int cost, PaymentProxy paymentMethod, String recaptchaResponse, HttpServletRequest request, Configurable configurable) { return (cost == 0 || paymentMethod == PaymentProxy.OFFLINE || paymentMethod == PaymentProxy.ON_SITE) - && configurationManager.isRecaptchaForOfflinePaymentAndFreeEnabled(ConfigurationLevel.event(event)) + && configurationManager.isRecaptchaForOfflinePaymentAndFreeEnabled(configurable.getConfigurationLevel()) && !recaptchaService.checkRecaptcha(recaptchaResponse, request); } } diff --git a/src/main/java/alfio/controller/api/v2/user/SubscriptionsApiController.java b/src/main/java/alfio/controller/api/v2/user/SubscriptionsApiController.java new file mode 100644 index 0000000000..fb2a0b4263 --- /dev/null +++ b/src/main/java/alfio/controller/api/v2/user/SubscriptionsApiController.java @@ -0,0 +1,161 @@ +/** + * 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; + +import alfio.controller.api.support.CurrencyDescriptor; +import alfio.controller.api.v2.model.AnalyticsConfiguration; +import alfio.controller.api.v2.model.BasicSubscriptionDescriptorInfo; +import alfio.controller.api.v2.model.DatesWithTimeZoneOffset; +import alfio.controller.api.v2.model.SubscriptionDescriptorWithAdditionalInfo; +import alfio.controller.api.v2.user.support.PurchaseContextInfoBuilder; +import alfio.controller.support.Formatters; +import alfio.manager.SubscriptionManager; +import alfio.manager.TicketReservationManager; +import alfio.manager.i18n.I18nManager; +import alfio.manager.i18n.MessageSourceManager; +import alfio.manager.support.response.ValidatedResponse; +import alfio.manager.system.ConfigurationManager; +import alfio.model.result.ValidationResult; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.repository.user.OrganizationRepository; +import alfio.util.ClockProvider; +import alfio.util.MonetaryUtil; +import org.joda.money.CurrencyUnit; +import org.springframework.context.MessageSource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpSession; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.stream.Collectors; + +import static alfio.model.PriceContainer.VatStatus.isVatIncluded; +import static alfio.model.system.ConfigurationKeys.BANK_ACCOUNT_NR; +import static alfio.model.system.ConfigurationKeys.BANK_ACCOUNT_OWNER; + +@RestController +@RequestMapping("/api/v2/public/") +public class SubscriptionsApiController { + + private final SubscriptionManager subscriptionManager; + private final I18nManager i18nManager; + private final TicketReservationManager reservationManager; + private final ConfigurationManager configurationManager; + private final OrganizationRepository organizationRepository; + private final MessageSourceManager messageSourceManager; + + public SubscriptionsApiController(SubscriptionManager subscriptionManager, + I18nManager i18nManager, + TicketReservationManager reservationManager, + ConfigurationManager configurationManager, + OrganizationRepository organizationRepository, + MessageSourceManager messageSourceManager) { + this.subscriptionManager = subscriptionManager; + this.i18nManager = i18nManager; + this.reservationManager = reservationManager; + this.configurationManager = configurationManager; + this.organizationRepository = organizationRepository; + this.messageSourceManager = messageSourceManager; + } + + @GetMapping("subscriptions") + public ResponseEntity> listSubscriptions(/* TODO search by: organizer, tag, subscription */) { + var contentLanguages = i18nManager.getAvailableLanguages(); + + var now = ZonedDateTime.now(ClockProvider.clock()); + var activeSubscriptions = subscriptionManager.getActivePublicSubscriptionsDescriptor(now) + .stream() + .map(s -> subscriptionDescriptorMapper(s, messageSourceManager.getMessageSourceFor(s))) + .collect(Collectors.toList()); + return ResponseEntity.ok(activeSubscriptions); + } + + private static BasicSubscriptionDescriptorInfo subscriptionDescriptorMapper(SubscriptionDescriptor s, MessageSource messageSource) { + var currencyUnit = CurrencyUnit.of(s.getCurrency()); + var currencyDescriptor = new CurrencyDescriptor(currencyUnit.getCode(), currencyUnit.toCurrency().getDisplayName(), currencyUnit.getSymbol(), currencyUnit.getDecimalPlaces()); + return new BasicSubscriptionDescriptorInfo(s.getId(), + s.getFileBlobId(), + s.getTitle(), + s.getDescription(), + DatesWithTimeZoneOffset.fromDates(s.getOnSaleFrom(), s.getOnSaleTo()), + s.getValidityType(), + s.getUsageType(), + s.getZoneId(), + s.getValidityTimeUnit(), + s.getValidityUnits(), + s.getMaxEntries(), + + null, + null, + + MonetaryUtil.formatCents(s.getPrice(), s.getCurrency()), + s.getCurrency(), + currencyDescriptor, + s.getVat(), + isVatIncluded(s.getVatStatus()), + Formatters.getFormattedDate(s, s.getOnSaleFrom(), "common.event.date-format", messageSource), + Formatters.getFormattedDate(s, s.getOnSaleTo(), "common.event.date-format", messageSource), + Formatters.getFormattedDate(s, s.getValidityFrom(), "common.event.date-format", messageSource), + Formatters.getFormattedDate(s, s.getValidityTo(), "common.event.date-format", messageSource) + ); + } + + @GetMapping("subscription/{id}") + public ResponseEntity getSubscriptionInfo(@PathVariable("id") String id, HttpSession session) { + var res = subscriptionManager.getSubscriptionById(UUID.fromString(id)); + return res + .map(s -> { + var configurationsValues = PurchaseContextInfoBuilder.configurationsValues(s, configurationManager); + var invoicingInfo = PurchaseContextInfoBuilder.invoicingInfo(configurationManager, configurationsValues); + var analyticsConf = AnalyticsConfiguration.build(configurationsValues, session); + var captchaConf = PurchaseContextInfoBuilder.captchaConfiguration(configurationManager, configurationsValues); + var bankAccount = configurationsValues.get(BANK_ACCOUNT_NR).getValueOrDefault(""); + var bankAccountOwner = Arrays.asList(configurationsValues.get(BANK_ACCOUNT_OWNER).getValueOrDefault("").split("\n")); + var orgContact = organizationRepository.getContactById(s.getOrganizationId()); + var messageSource = messageSourceManager.getMessageSourceFor(s); + return new SubscriptionDescriptorWithAdditionalInfo(s, + invoicingInfo, + analyticsConf, + captchaConf, + bankAccount, + bankAccountOwner, + orgContact.getEmail(), + orgContact.getName(), + DatesWithTimeZoneOffset.fromDates(s.getOnSaleFrom(), s.getOnSaleTo()), + Formatters.getFormattedDate(s, s.getOnSaleFrom(), "common.event.date-format", messageSource), + Formatters.getFormattedDate(s, s.getOnSaleTo(), "common.event.date-format", messageSource), + s.getZoneId().toString(), + Formatters.getFormattedDate(s, s.getValidityFrom(), "common.event.date-format", messageSource), + Formatters.getFormattedDate(s, s.getValidityTo(), "common.event.date-format", messageSource)); + }) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("subscription/{id}") + public ResponseEntity> reserveSubscription(@PathVariable("id") String id, Locale locale) { + return subscriptionManager.getSubscriptionById(UUID.fromString(id)) + .map(subscriptionDescriptor -> reservationManager.createSubscriptionReservation(subscriptionDescriptor, locale) + .map(reservationId -> ResponseEntity.ok(new ValidatedResponse<>(ValidationResult.success(), reservationId))) + .orElseGet(() -> ResponseEntity.unprocessableEntity().build())) + .orElseGet(() -> ResponseEntity.notFound().build()); + } +} 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 7a17d40626..c08acc1261 100644 --- a/src/main/java/alfio/controller/api/v2/user/TicketApiV2Controller.java +++ b/src/main/java/alfio/controller/api/v2/user/TicketApiV2Controller.java @@ -204,7 +204,7 @@ public ResponseEntity getTicketInfo(@PathVariable("eventName") Strin var sameDay = validityStart.truncatedTo(ChronoUnit.DAYS).equals(validityEnd.truncatedTo(ChronoUnit.DAYS)); - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); var formattedDates = Formatters.getFormattedDates(event, messageSource, event.getContentLanguages()); // diff --git a/src/main/java/alfio/controller/api/v2/user/support/BookingInfoTicketLoader.java b/src/main/java/alfio/controller/api/v2/user/support/BookingInfoTicketLoader.java index bc1fd1fd71..83ed4de685 100644 --- a/src/main/java/alfio/controller/api/v2/user/support/BookingInfoTicketLoader.java +++ b/src/main/java/alfio/controller/api/v2/user/support/BookingInfoTicketLoader.java @@ -72,7 +72,7 @@ public ReservationInfo.BookingInfoTicket toBookingInfoTicket(Ticket ticket, Even .map(link -> link.getValidFrom().atZone(event.getZoneId())) .orElse(event.getBegin()); formattedDates = Formatters.getFormattedDate(event, checkInDate, "common.ticket-category.date-format", - messageSourceManager.getMessageSourceForEvent(event)); + messageSourceManager.getMessageSourceFor(event)); onlineEventStarted = event.now(clockProvider).isAfter(checkInDate); } diff --git a/src/main/java/alfio/controller/api/v2/user/support/EventLoader.java b/src/main/java/alfio/controller/api/v2/user/support/EventLoader.java index 661da4b9a8..5fc2e85b9c 100644 --- a/src/main/java/alfio/controller/api/v2/user/support/EventLoader.java +++ b/src/main/java/alfio/controller/api/v2/user/support/EventLoader.java @@ -19,9 +19,7 @@ import alfio.controller.api.v2.model.AnalyticsConfiguration; import alfio.controller.api.v2.model.EventWithAdditionalInfo; import alfio.controller.support.Formatters; -import alfio.manager.EuVatChecker; import alfio.manager.i18n.MessageSourceManager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.Event; import alfio.model.modification.support.LocationDescriptor; @@ -29,7 +27,6 @@ import alfio.repository.*; import alfio.repository.user.OrganizationRepository; import lombok.AllArgsConstructor; -import org.apache.commons.lang3.Validate; import org.springframework.stereotype.Component; import javax.servlet.http.HttpSession; @@ -49,12 +46,13 @@ public class EventLoader { private final TicketCategoryRepository ticketCategoryRepository; private final TicketRepository ticketRepository; private final PromoCodeDiscountRepository promoCodeRepository; + private final SubscriptionRepository subscriptionRepository; public Optional loadEventInfo(String eventName, HttpSession session) { return eventRepository.findOptionalByShortName(eventName).filter(e -> e.getStatus() != Event.Status.DISABLED)// .map(event -> { // - var messageSourceAndOverride = messageSourceManager.getMessageSourceForEventAndOverride(event); + var messageSourceAndOverride = messageSourceManager.getMessageSourceForPurchaseContextAndOverride(event); var messageSource = messageSourceAndOverride.getLeft(); var i18nOverride = messageSourceAndOverride.getRight(); @@ -62,51 +60,12 @@ public Optional loadEventInfo(String eventName, HttpSes var organization = organizationRepository.getContactById(event.getOrganizationId()); - var configurationsValues = configurationManager.getFor(List.of( - MAPS_PROVIDER, - MAPS_CLIENT_API_KEY, - MAPS_HERE_API_KEY, - RECAPTCHA_API_KEY, - BANK_ACCOUNT_NR, - BANK_ACCOUNT_OWNER, - ENABLE_CUSTOMER_REFERENCE, - ENABLE_ITALY_E_INVOICING, - VAT_NUMBER_IS_REQUIRED, - FORCE_TICKET_OWNER_ASSIGNMENT_AT_RESERVATION, - ENABLE_ATTENDEE_AUTOCOMPLETE, - ENABLE_TICKET_TRANSFER, - DISPLAY_DISCOUNT_CODE_BOX, - USE_PARTNER_CODE_INSTEAD_OF_PROMOTIONAL, - GOOGLE_ANALYTICS_KEY, - GOOGLE_ANALYTICS_ANONYMOUS_MODE, - // captcha - ENABLE_CAPTCHA_FOR_TICKET_SELECTION, - RECAPTCHA_API_KEY, - ENABLE_CAPTCHA_FOR_OFFLINE_PAYMENTS, - // - GENERATE_ONLY_INVOICE, - // - INVOICE_ADDRESS, - VAT_NR, - // required by EuVatChecker.reverseChargeEnabled - ENABLE_EU_VAT_DIRECTIVE, - COUNTRY_OF_BUSINESS, - - DISPLAY_TICKETS_LEFT_INDICATOR, - EVENT_CUSTOM_CSS - ), ConfigurationLevel.event(event)); + var configurationsValues = PurchaseContextInfoBuilder.configurationsValues(event, configurationManager); var locationDescriptor = LocationDescriptor.fromGeoData(event.getFormat(), event.getLatLong(), TimeZone.getTimeZone(event.getTimeZone()), configurationsValues); // - boolean captchaForTicketSelection = isRecaptchaForTicketSelectionEnabled(configurationsValues); - String recaptchaApiKey = null; - if (captchaForTicketSelection) { - recaptchaApiKey = configurationsValues.get(RECAPTCHA_API_KEY).getValueOrNull(); - } - // - boolean captchaForOfflinePaymentAndFreeEnabled = configurationManager.isRecaptchaForOfflinePaymentAndFreeEnabled(configurationsValues); - var captchaConf = new EventWithAdditionalInfo.CaptchaConfiguration(captchaForTicketSelection, captchaForOfflinePaymentAndFreeEnabled, recaptchaApiKey); + var captchaConf = PurchaseContextInfoBuilder.captchaConfiguration(configurationManager, configurationsValues); // @@ -117,17 +76,7 @@ public Optional loadEventInfo(String eventName, HttpSes var formattedDates = Formatters.getFormattedDates(event, messageSource, event.getContentLanguages()); //invoicing information - boolean canGenerateReceiptOrInvoiceToCustomer = configurationManager.canGenerateReceiptOrInvoiceToCustomer(configurationsValues); - boolean euVatCheckingEnabled = EuVatChecker.reverseChargeEnabled(configurationsValues); - boolean invoiceAllowed = configurationManager.hasAllConfigurationsForInvoice(configurationsValues); - boolean onlyInvoice = invoiceAllowed && configurationManager.isInvoiceOnly(configurationsValues); - boolean customerReferenceEnabled = configurationsValues.get(ENABLE_CUSTOMER_REFERENCE).getValueAsBooleanOrDefault(); - boolean enabledItalyEInvoicing = configurationsValues.get(ENABLE_ITALY_E_INVOICING).getValueAsBooleanOrDefault(); - boolean vatNumberStrictlyRequired = configurationsValues.get(VAT_NUMBER_IS_REQUIRED).getValueAsBooleanOrDefault(); - - var invoicingConf = new EventWithAdditionalInfo.InvoicingConfiguration(canGenerateReceiptOrInvoiceToCustomer, - euVatCheckingEnabled, invoiceAllowed, onlyInvoice, - customerReferenceEnabled, enabledItalyEInvoicing, vatNumberStrictlyRequired); + var invoicingConf = PurchaseContextInfoBuilder.invoicingInfo(configurationManager, configurationsValues); // // @@ -157,18 +106,18 @@ public Optional loadEventInfo(String eventName, HttpSes var customCss = configurationsValues.get(EVENT_CUSTOM_CSS).getValueOrNull(); + var hasLinkedSubscription = subscriptionRepository.hasLinkedSubscription(event.getId()); + return new EventWithAdditionalInfo(event, locationDescriptor.getMapUrl(), organization, descriptions, bankAccount, bankAccountOwner, formattedDates.beginDate, formattedDates.beginTime, formattedDates.endDate, formattedDates.endTime, invoicingConf, captchaConf, assignmentConf, promoConf, analyticsConf, - MessageSourceManager.convertPlaceholdersForEachLanguage(i18nOverride), availableTicketsCount, customCss); + MessageSourceManager.convertPlaceholdersForEachLanguage(i18nOverride), availableTicketsCount, customCss, hasLinkedSubscription); }); } public boolean isRecaptchaForTicketSelectionEnabled(Map configurationValues) { - Validate.isTrue(configurationValues.containsKey(ENABLE_CAPTCHA_FOR_TICKET_SELECTION) && configurationValues.containsKey(RECAPTCHA_API_KEY)); - return configurationValues.get(ENABLE_CAPTCHA_FOR_TICKET_SELECTION).getValueAsBooleanOrDefault() && - configurationValues.get(RECAPTCHA_API_KEY).getValueOrNull() != null; + return PurchaseContextInfoBuilder.isRecaptchaForTicketSelectionEnabled(configurationValues); } } diff --git a/src/main/java/alfio/controller/api/v2/user/support/PurchaseContextInfoBuilder.java b/src/main/java/alfio/controller/api/v2/user/support/PurchaseContextInfoBuilder.java new file mode 100644 index 0000000000..23019f76d1 --- /dev/null +++ b/src/main/java/alfio/controller/api/v2/user/support/PurchaseContextInfoBuilder.java @@ -0,0 +1,100 @@ +/** + * 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.support; + +import alfio.controller.api.v2.model.EventWithAdditionalInfo; +import alfio.manager.EuVatChecker; +import alfio.manager.system.ConfigurationManager; +import alfio.model.PurchaseContext; +import alfio.model.system.ConfigurationKeys; +import org.apache.commons.lang3.Validate; + +import java.util.List; +import java.util.Map; + +import static alfio.model.system.ConfigurationKeys.*; + +public class PurchaseContextInfoBuilder { + + public static Map configurationsValues(PurchaseContext purchaseContext, ConfigurationManager configurationManager) { + return configurationManager.getFor(List.of( + MAPS_PROVIDER, + MAPS_CLIENT_API_KEY, + MAPS_HERE_API_KEY, + RECAPTCHA_API_KEY, + BANK_ACCOUNT_NR, + BANK_ACCOUNT_OWNER, + ENABLE_CUSTOMER_REFERENCE, + ENABLE_ITALY_E_INVOICING, + VAT_NUMBER_IS_REQUIRED, + FORCE_TICKET_OWNER_ASSIGNMENT_AT_RESERVATION, + ENABLE_ATTENDEE_AUTOCOMPLETE, + ENABLE_TICKET_TRANSFER, + DISPLAY_DISCOUNT_CODE_BOX, + USE_PARTNER_CODE_INSTEAD_OF_PROMOTIONAL, + GOOGLE_ANALYTICS_KEY, + GOOGLE_ANALYTICS_ANONYMOUS_MODE, + // captcha + ENABLE_CAPTCHA_FOR_TICKET_SELECTION, + RECAPTCHA_API_KEY, + ENABLE_CAPTCHA_FOR_OFFLINE_PAYMENTS, + // + GENERATE_ONLY_INVOICE, + // + INVOICE_ADDRESS, + VAT_NR, + // required by EuVatChecker.reverseChargeEnabled + ENABLE_EU_VAT_DIRECTIVE, + COUNTRY_OF_BUSINESS, + + DISPLAY_TICKETS_LEFT_INDICATOR, + EVENT_CUSTOM_CSS + ), purchaseContext.getConfigurationLevel()); + } + + public static EventWithAdditionalInfo.InvoicingConfiguration invoicingInfo(ConfigurationManager configurationManager, Map configurationsValues) { + boolean canGenerateReceiptOrInvoiceToCustomer = configurationManager.canGenerateReceiptOrInvoiceToCustomer(configurationsValues); + boolean euVatCheckingEnabled = EuVatChecker.reverseChargeEnabled(configurationsValues); + boolean invoiceAllowed = configurationManager.hasAllConfigurationsForInvoice(configurationsValues); + boolean onlyInvoice = invoiceAllowed && configurationManager.isInvoiceOnly(configurationsValues); + boolean customerReferenceEnabled = configurationsValues.get(ENABLE_CUSTOMER_REFERENCE).getValueAsBooleanOrDefault(); + boolean enabledItalyEInvoicing = configurationsValues.get(ENABLE_ITALY_E_INVOICING).getValueAsBooleanOrDefault(); + boolean vatNumberStrictlyRequired = configurationsValues.get(VAT_NUMBER_IS_REQUIRED).getValueAsBooleanOrDefault(); + + return new EventWithAdditionalInfo.InvoicingConfiguration(canGenerateReceiptOrInvoiceToCustomer, + euVatCheckingEnabled, invoiceAllowed, onlyInvoice, + customerReferenceEnabled, enabledItalyEInvoicing, vatNumberStrictlyRequired); + + } + + public static EventWithAdditionalInfo.CaptchaConfiguration captchaConfiguration(ConfigurationManager configurationManager, Map configurationsValues) { + boolean captchaForTicketSelection = isRecaptchaForTicketSelectionEnabled(configurationsValues); + String recaptchaApiKey = null; + if (captchaForTicketSelection) { + recaptchaApiKey = configurationsValues.get(RECAPTCHA_API_KEY).getValueOrNull(); + } + // + boolean captchaForOfflinePaymentAndFreeEnabled = configurationManager.isRecaptchaForOfflinePaymentAndFreeEnabled(configurationsValues); + return new EventWithAdditionalInfo.CaptchaConfiguration(captchaForTicketSelection, captchaForOfflinePaymentAndFreeEnabled, recaptchaApiKey); + } + + public static boolean isRecaptchaForTicketSelectionEnabled(Map configurationValues) { + Validate.isTrue(configurationValues.containsKey(ENABLE_CAPTCHA_FOR_TICKET_SELECTION) && configurationValues.containsKey(RECAPTCHA_API_KEY)); + return configurationValues.get(ENABLE_CAPTCHA_FOR_TICKET_SELECTION).getValueAsBooleanOrDefault() && + configurationValues.get(RECAPTCHA_API_KEY).getValueOrNull() != null; + } +} diff --git a/src/main/java/alfio/controller/form/ContactAndTicketsForm.java b/src/main/java/alfio/controller/form/ContactAndTicketsForm.java index 6cc74d647d..237a0aeb8f 100644 --- a/src/main/java/alfio/controller/form/ContactAndTicketsForm.java +++ b/src/main/java/alfio/controller/form/ContactAndTicketsForm.java @@ -18,6 +18,7 @@ import alfio.manager.SameCountryValidator; import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.TicketReservationInvoicingAdditionalInfo.ItalianEInvoicing; import alfio.model.result.ValidationResult; import alfio.model.system.ConfigurationKeys; @@ -83,10 +84,10 @@ private static void rejectIfOverLength(BindingResult bindingResult, String field - public void validate(BindingResult bindingResult, Event event, + public void validate(BindingResult bindingResult, PurchaseContext purchaseContext, SameCountryValidator vatValidator, Map formValidationParameters, - Validator.TicketFieldsFilterer ticketFieldsFilterer) { + Optional ticketFieldsFilterer) { @@ -101,15 +102,12 @@ public void validate(BindingResult bindingResult, Event event, ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "email", ErrorsCode.STEP_2_EMPTY_EMAIL); rejectIfOverLength(bindingResult, "email", ErrorsCode.STEP_2_MAX_LENGTH_EMAIL, email, 255); - if(event.mustUseFirstAndLastName()) { - ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "firstName", ErrorsCode.STEP_2_EMPTY_FIRSTNAME); - rejectIfOverLength(bindingResult, "firstName", ErrorsCode.STEP_2_MAX_LENGTH_FIRSTNAME, fullName, 255); - ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "lastName", ErrorsCode.STEP_2_EMPTY_LASTNAME); - rejectIfOverLength(bindingResult, "lastName", ErrorsCode.STEP_2_MAX_LENGTH_LASTNAME, fullName, 255); - } else { - ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "fullName", ErrorsCode.STEP_2_EMPTY_FULLNAME); - rejectIfOverLength(bindingResult, "fullName", ErrorsCode.STEP_2_MAX_LENGTH_FULLNAME, fullName, 255); - } + + ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "firstName", ErrorsCode.STEP_2_EMPTY_FIRSTNAME); + rejectIfOverLength(bindingResult, "firstName", ErrorsCode.STEP_2_MAX_LENGTH_FIRSTNAME, fullName, 255); + ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "lastName", ErrorsCode.STEP_2_EMPTY_LASTNAME); + rejectIfOverLength(bindingResult, "lastName", ErrorsCode.STEP_2_MAX_LENGTH_LASTNAME, fullName, 255); + @@ -173,23 +171,25 @@ public void validate(BindingResult bindingResult, Event event, bindingResult.rejectValue("email", ErrorsCode.STEP_2_INVALID_EMAIL, new Object[] {}, null); } - if(!postponeAssignment) { - Optional> validationResults = Optional.ofNullable(tickets) - .filter(m -> !m.isEmpty()) - .map(m -> m.entrySet().stream().map(e -> { - var filteredForTicket = ticketFieldsFilterer.getFieldsForTicket(e.getKey()); - return Validator.validateTicketAssignment(e.getValue(), filteredForTicket, Optional.of(bindingResult), event, "tickets[" + e.getKey() + "]", vatValidator); - })) - .map(s -> s.collect(Collectors.toList())); - - boolean success = validationResults - .filter(l -> l.stream().allMatch(ValidationResult::isSuccess)) - .isPresent(); - if(!success) { - String errorCode = validationResults.filter(this::containsVatValidationError).isPresent() ? STEP_2_INVALID_VAT : STEP_2_MISSING_ATTENDEE_DATA; - bindingResult.reject(errorCode); + purchaseContext.event().ifPresent(event -> { + if(!postponeAssignment) { + Optional> validationResults = Optional.ofNullable(tickets) + .filter(m -> !m.isEmpty()) + .map(m -> m.entrySet().stream().map(e -> { + var filteredForTicket = ticketFieldsFilterer.orElseThrow().getFieldsForTicket(e.getKey()); + return Validator.validateTicketAssignment(e.getValue(), filteredForTicket, Optional.of(bindingResult), event, "tickets[" + e.getKey() + "]", vatValidator); + })) + .map(s -> s.collect(Collectors.toList())); + + boolean success = validationResults + .filter(l -> l.stream().allMatch(ValidationResult::isSuccess)) + .isPresent(); + if(!success) { + String errorCode = validationResults.filter(this::containsVatValidationError).isPresent() ? STEP_2_INVALID_VAT : STEP_2_MISSING_ATTENDEE_DATA; + bindingResult.reject(errorCode); + } } - } + }); } private boolean containsVatValidationError(List l) { diff --git a/src/main/java/alfio/controller/form/EventSearchOptions.java b/src/main/java/alfio/controller/form/EventSearchOptions.java new file mode 100644 index 0000000000..4b3d94fd53 --- /dev/null +++ b/src/main/java/alfio/controller/form/EventSearchOptions.java @@ -0,0 +1,56 @@ +/** + * 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.form; + +import lombok.Data; +import lombok.extern.log4j.Log4j2; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.UUID; + +@Data +@Log4j2 +public class EventSearchOptions { + private String subscription; + private Integer organizer; + private List tags; + + public static EventSearchOptions empty() { + return new EventSearchOptions(); + } + + public boolean isEmpty() { + return StringUtils.isEmpty(subscription) + && organizer != null + && organizer > 0 + && CollectionUtils.isEmpty(tags); + } + + public UUID getSubscriptionCodeUUIDOrNull() { + try { + if(StringUtils.isEmpty(subscription)) { + return null; + } + return UUID.fromString(subscription); + } catch (Exception e) { + log.warn("invalid UUID received: {}", subscription); + return null; + } + } +} diff --git a/src/main/java/alfio/controller/form/PaymentForm.java b/src/main/java/alfio/controller/form/PaymentForm.java index b4a5b93b6a..acdbaa481d 100644 --- a/src/main/java/alfio/controller/form/PaymentForm.java +++ b/src/main/java/alfio/controller/form/PaymentForm.java @@ -16,7 +16,7 @@ */ package alfio.controller.form; -import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.TotalPrice; import alfio.model.transaction.PaymentMethod; import alfio.model.transaction.PaymentProxy; @@ -42,8 +42,8 @@ public class PaymentForm implements Serializable { private String captcha; - public void validate(BindingResult bindingResult, Event event, TotalPrice reservationCost) { - List allowedProxies = event.getAllowedPaymentProxies(); + public void validate(BindingResult bindingResult, PurchaseContext purchaseContext, TotalPrice reservationCost) { + List allowedProxies = purchaseContext.getAllowedPaymentProxies(); Optional paymentProxyOptional = Optional.ofNullable(paymentProxy); boolean priceGreaterThanZero = reservationCost.getPriceWithVAT() > 0; @@ -53,7 +53,7 @@ public void validate(BindingResult bindingResult, Event event, TotalPrice reserv } if (Objects.isNull(termAndConditionsAccepted) || !termAndConditionsAccepted - || (StringUtils.isNotEmpty(event.getPrivacyPolicyUrl()) && (Objects.isNull(privacyPolicyAccepted) || !privacyPolicyAccepted))) { + || (StringUtils.isNotEmpty(purchaseContext.getPrivacyPolicyUrl()) && (Objects.isNull(privacyPolicyAccepted) || !privacyPolicyAccepted))) { bindingResult.reject(ErrorsCode.STEP_2_TERMS_NOT_ACCEPTED); } } diff --git a/src/main/java/alfio/controller/form/ReservationCodeForm.java b/src/main/java/alfio/controller/form/ReservationCodeForm.java new file mode 100644 index 0000000000..b11096c3fd --- /dev/null +++ b/src/main/java/alfio/controller/form/ReservationCodeForm.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.controller.form; + +import lombok.Data; + +import java.io.Serializable; +import java.util.UUID; + +@Data +public class ReservationCodeForm implements Serializable { + + private String code; + private String email; + private int amount; + private ReservationCodeType type; + + + public enum ReservationCodeType { + SUBSCRIPTION + } + + public boolean isCodeUUID() { + try { + UUID.fromString(code); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/alfio/controller/payment/PayPalCallbackController.java b/src/main/java/alfio/controller/payment/PayPalCallbackController.java index 6917a12d54..c8e361d07d 100644 --- a/src/main/java/alfio/controller/payment/PayPalCallbackController.java +++ b/src/main/java/alfio/controller/payment/PayPalCallbackController.java @@ -16,12 +16,11 @@ */ package alfio.controller.payment; +import alfio.manager.PurchaseContextManager; import alfio.manager.TicketReservationManager; import alfio.manager.payment.PayPalManager; -import alfio.model.Event; import alfio.model.TicketReservation; import alfio.model.transaction.token.PayPalToken; -import alfio.repository.EventRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -34,61 +33,63 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; @Controller -@RequestMapping("/event/{eventName}/reservation/{reservationId}/payment/paypal") +@RequestMapping("/reservation/{reservationId}/payment/paypal") @RequiredArgsConstructor public class PayPalCallbackController { - private final EventRepository eventRepository; + private final PurchaseContextManager purchaseContextManager; private final TicketReservationManager ticketReservationManager; private final PayPalManager payPalManager; @GetMapping("/confirm") - public String payPalSuccess(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + public String payPalSuccess(@PathVariable("reservationId") String reservationId, @RequestParam(value = "token", required = false) String payPalPaymentId, @RequestParam(value = "PayerID", required = false) String payPalPayerID, @RequestParam(value = "hmac") String hmac) { - Optional optionalEvent = eventRepository.findOptionalByShortName(eventName); - if(optionalEvent.isEmpty()) { + var optionalPurchaseContext = purchaseContextManager.findByReservationId(reservationId); + if(optionalPurchaseContext.isEmpty()) { return "redirect:/"; } Optional optionalReservation = ticketReservationManager.findById(reservationId); + var purchaseContext = optionalPurchaseContext.get(); + if(optionalReservation.isEmpty()) { - return "redirect:/event/" + eventName; + return "redirect:/"+purchaseContext.getType().getUrlComponent()+"/" + purchaseContext.getPublicIdentifier(); } var res = optionalReservation.get(); - var ev = optionalEvent.get(); + if (isNotBlank(payPalPayerID) && isNotBlank(payPalPaymentId)) { var token = new PayPalToken(payPalPayerID, payPalPaymentId, hmac); - payPalManager.saveToken(res.getId(), ev, token); - return "redirect:/event/" + ev.getShortName() + "/reservation/" +res.getId() + "/overview"; + payPalManager.saveToken(res.getId(), purchaseContext, token); + return "redirect:/" + purchaseContext.getType().getUrlComponent() + "/" + purchaseContext.getPublicIdentifier() + "/reservation/" +res.getId() + "/overview"; } else { - return payPalCancel(ev.getShortName(), res.getId(), payPalPaymentId, hmac); + return payPalCancel(res.getId(), payPalPaymentId, hmac); } } @GetMapping("/cancel") - public String payPalCancel(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + public String payPalCancel(@PathVariable("reservationId") String reservationId, @RequestParam(value = "token", required = false) String payPalPaymentId, @RequestParam(value = "hmac") String hmac) { - if(eventRepository.findOptionalByShortName(eventName).isEmpty()) { + var optionalPurchaseContext = purchaseContextManager.findByReservationId(reservationId); + if(optionalPurchaseContext.isEmpty()) { return "redirect:/"; } + var purchaseContext = optionalPurchaseContext.get(); Optional optionalReservation = ticketReservationManager.findById(reservationId); if(optionalReservation.isEmpty()) { - return "redirect:/event/" + eventName; + return "redirect:/" + purchaseContext.getType().getUrlComponent() + "/" + purchaseContext.getPublicIdentifier(); } payPalManager.removeToken(optionalReservation.get(), payPalPaymentId); - return "redirect:/event/"+eventName+"/reservation/"+reservationId+"/overview"; + return "redirect:/" + purchaseContext.getType().getUrlComponent() + "/" + purchaseContext.getPublicIdentifier() + "/reservation/" + optionalReservation.get().getId() + "/overview"; } } diff --git a/src/main/java/alfio/controller/payment/SaferpayCallbackController.java b/src/main/java/alfio/controller/payment/SaferpayCallbackController.java index 91581de7b2..2583280bf9 100644 --- a/src/main/java/alfio/controller/payment/SaferpayCallbackController.java +++ b/src/main/java/alfio/controller/payment/SaferpayCallbackController.java @@ -16,9 +16,10 @@ */ package alfio.controller.payment; +import alfio.manager.PurchaseContextManager; import alfio.manager.TicketReservationManager; import alfio.manager.payment.saferpay.PaymentPageInitializeRequestBuilder; -import alfio.repository.EventRepository; +import alfio.model.PurchaseContext; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -30,26 +31,27 @@ public class SaferpayCallbackController { private final TicketReservationManager ticketReservationManager; - private final EventRepository eventRepository; + private final PurchaseContextManager purchaseContextManager; @GetMapping(PaymentPageInitializeRequestBuilder.CANCEL_URL_TEMPLATE) - public String saferpayCancel(@PathVariable("eventName") String eventName, + public String saferpayCancel(@PathVariable("purchaseContextType") PurchaseContext.PurchaseContextType purchaseContextType, + @PathVariable("purchaseContextIdentifier") String purchaseContextIdentifier, @PathVariable("reservationId") String reservationId) { - var optionalEvent = eventRepository.findOptionalByShortName(eventName); - if(optionalEvent.isEmpty()) { + var maybePurchaseContext = purchaseContextManager.findBy(purchaseContextType, purchaseContextIdentifier); + if(maybePurchaseContext.isEmpty()) { return "redirect:/"; } - var event = optionalEvent.get(); - var optionalReservation = ticketReservationManager.findByIdForEvent(reservationId, event.getId()); + var purchaseContext = maybePurchaseContext.get(); + var optionalReservation = ticketReservationManager.findById(reservationId); if(optionalReservation.isEmpty()) { - return "redirect:/event/"+eventName; + return "redirect:/"+purchaseContext.getType().getUrlComponent()+"/"+purchaseContext.getPublicIdentifier(); } - var optionalResult = ticketReservationManager.forceTransactionCheck(event, optionalReservation.get()); + var optionalResult = ticketReservationManager.forceTransactionCheck(purchaseContext, optionalReservation.get()); if(optionalResult.isEmpty()) { // there's no transaction available. - return "redirect:/event/"+eventName; + return "redirect:/"+purchaseContext.getType().getUrlComponent()+"/"+purchaseContext.getPublicIdentifier(); } return "redirect:" + UriComponentsBuilder.fromPath(PaymentPageInitializeRequestBuilder.SUCCESS_URL_TEMPLATE) - .buildAndExpand(eventName, reservationId).toUriString(); + .buildAndExpand(purchaseContext.getType().getUrlComponent(), purchaseContext.getPublicIdentifier(), reservationId).toUriString(); } } diff --git a/src/main/java/alfio/controller/payment/api/PaymentApiController.java b/src/main/java/alfio/controller/payment/api/PaymentApiController.java index 6d4726fef1..5aeb600f3f 100644 --- a/src/main/java/alfio/controller/payment/api/PaymentApiController.java +++ b/src/main/java/alfio/controller/payment/api/PaymentApiController.java @@ -17,13 +17,13 @@ package alfio.controller.payment.api; import alfio.manager.PaymentManager; +import alfio.manager.PurchaseContextManager; import alfio.manager.TicketReservationManager; import alfio.manager.support.PaymentResult; -import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.TicketReservation; import alfio.model.transaction.PaymentMethod; import alfio.model.transaction.TransactionInitializationToken; -import alfio.repository.EventRepository; import lombok.AllArgsConstructor; import org.apache.commons.lang3.tuple.Pair; import org.springframework.http.ResponseEntity; @@ -37,12 +37,13 @@ public class PaymentApiController { private final PaymentManager paymentManager; - private final EventRepository eventRepository; private final TicketReservationManager ticketReservationManager; + private final PurchaseContextManager purchaseContextManager; - @PostMapping("/api/events/{eventName}/reservation/{reservationId}/payment/{method}/init") - public ResponseEntity initTransaction(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + @PostMapping({"/api/reservation/{reservationId}/payment/{method}/init", + "/api/events/{eventName}/reservation/{reservationId}/payment/{method}/init" //<-deprecated + }) + public ResponseEntity initTransaction(@PathVariable("reservationId") String reservationId, @PathVariable("method") String paymentMethodStr, @RequestParam MultiValueMap allParams) { @@ -51,38 +52,42 @@ public ResponseEntity initTransaction(@PathVaria return ResponseEntity.badRequest().build(); } - return getEventReservationPair(eventName, reservationId) + return getEventReservationPair(reservationId) .flatMap(pair -> ticketReservationManager.initTransaction(pair.getLeft(), reservationId, paymentMethod, allParams)) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } - private Optional> getEventReservationPair(String eventName, String reservationId) { - return eventRepository.findOptionalByShortName(eventName) + 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("/api/events/{eventName}/reservation/{reservationId}/payment/{method}/status") - public ResponseEntity getTransactionStatus(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId, + @GetMapping({ + "/api/reservation/{reservationId}/payment/{method}/status", + "/api/events/{eventName}/reservation/{reservationId}/payment/{method}/status" //<-deprecated + }) + public ResponseEntity getTransactionStatus(@PathVariable("reservationId") String reservationId, @PathVariable("method") String paymentMethodStr) { var paymentMethod = PaymentMethod.safeParse(paymentMethodStr); if (paymentMethod == null) { return ResponseEntity.badRequest().build(); } - return getEventReservationPair(eventName, reservationId) + return getEventReservationPair(reservationId) .flatMap(pair -> paymentManager.getTransactionStatus(pair.getRight(), paymentMethod)) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } - @GetMapping("/api/v2/public/event/{eventName}/reservation/{reservationId}/transaction/force-check") - public ResponseEntity forceCheckStatus(@PathVariable("eventName") String eventName, - @PathVariable("reservationId") String reservationId) { - return ResponseEntity.of(getEventReservationPair(eventName, reservationId) + @GetMapping({ + "/api/v2/public/reservation/{reservationId}/transaction/force-check", + "/api/v2/public/event/{eventName}/reservation/{reservationId}/transaction/force-check" //<-deprecated + }) + public ResponseEntity forceCheckStatus(@PathVariable("reservationId") String reservationId) { + return ResponseEntity.of(getEventReservationPair(reservationId) .flatMap(pair -> ticketReservationManager.forceTransactionCheck(pair.getLeft(), pair.getRight()))); } } diff --git a/src/main/java/alfio/controller/payment/api/mollie/MolliePaymentWebhookController.java b/src/main/java/alfio/controller/payment/api/mollie/MolliePaymentWebhookController.java index fc5730209e..4e7f215981 100644 --- a/src/main/java/alfio/controller/payment/api/mollie/MolliePaymentWebhookController.java +++ b/src/main/java/alfio/controller/payment/api/mollie/MolliePaymentWebhookController.java @@ -16,10 +16,10 @@ */ package alfio.controller.payment.api.mollie; +import alfio.manager.PurchaseContextManager; import alfio.manager.TicketReservationManager; import alfio.model.transaction.PaymentContext; import alfio.model.transaction.PaymentProxy; -import alfio.repository.EventRepository; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.StringUtils; @@ -33,26 +33,27 @@ import java.util.Map; import java.util.Optional; -import static alfio.manager.payment.MollieWebhookPaymentManager.WEBHOOK_URL_TEMPLATE; +import static alfio.manager.payment.MollieWebhookPaymentManager.*; @RestController @Log4j2 @AllArgsConstructor public class MolliePaymentWebhookController { private final TicketReservationManager ticketReservationManager; - private final EventRepository eventRepository; + private final PurchaseContextManager purchaseContextManager; @SuppressWarnings("MVCPathVariableInspection") @PostMapping(WEBHOOK_URL_TEMPLATE) public ResponseEntity receivePaymentConfirmation(HttpServletRequest request, - @PathVariable("eventShortName") String eventName, @PathVariable("reservationId") String reservationId) { return Optional.ofNullable(StringUtils.trimToNull(request.getParameter("id"))) - .flatMap(id -> eventRepository.findOptionalByShortName(eventName) - .map(event -> { + .flatMap(id -> purchaseContextManager.findByReservationId(reservationId) + .map(purchaseContext -> { var content = "id="+id; var result = ticketReservationManager.processTransactionWebhook(content, null, PaymentProxy.MOLLIE, - Map.of("eventName", eventName, "reservationId", reservationId), new PaymentContext(event, reservationId)); + Map.of(ADDITIONAL_INFO_PURCHASE_CONTEXT_TYPE, purchaseContext.getType().getUrlComponent(), + ADDITIONAL_INFO_PURCHASE_IDENTIFIER, purchaseContext.getPublicIdentifier(), + ADDITIONAL_INFO_RESERVATION_ID, reservationId), new PaymentContext(purchaseContext, reservationId)); if(result.isSuccessful()) { return ResponseEntity.ok("OK"); } else if(result.isError()) { diff --git a/src/main/java/alfio/controller/payment/api/saferpay/SaferpayPaymentWebhookController.java b/src/main/java/alfio/controller/payment/api/saferpay/SaferpayPaymentWebhookController.java index 17adf6f4da..8319ac40cd 100644 --- a/src/main/java/alfio/controller/payment/api/saferpay/SaferpayPaymentWebhookController.java +++ b/src/main/java/alfio/controller/payment/api/saferpay/SaferpayPaymentWebhookController.java @@ -16,11 +16,11 @@ */ package alfio.controller.payment.api.saferpay; +import alfio.manager.PurchaseContextManager; import alfio.manager.TicketReservationManager; import alfio.manager.payment.saferpay.PaymentPageInitializeRequestBuilder; import alfio.model.transaction.PaymentContext; import alfio.model.transaction.PaymentProxy; -import alfio.repository.EventRepository; import lombok.AllArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -34,15 +34,16 @@ @AllArgsConstructor public class SaferpayPaymentWebhookController { private final TicketReservationManager ticketReservationManager; - private final EventRepository eventRepository; + private final PurchaseContextManager purchaseContextManager; @GetMapping(PaymentPageInitializeRequestBuilder.WEBHOOK_URL_TEMPLATE) - ResponseEntity handleTransactionNotification(@PathVariable("eventShortName") String eventName, - @PathVariable("reservationId") String reservationId) { - return eventRepository.findOptionalByShortName(eventName) - .map(event -> { + ResponseEntity handleTransactionNotification(@PathVariable("reservationId") String reservationId) { + return purchaseContextManager.findByReservationId(reservationId) + .map(purchaseContext -> { var result = ticketReservationManager.processTransactionWebhook("", null, PaymentProxy.SAFERPAY, - Map.of("eventName", eventName, "reservationId", reservationId), new PaymentContext(event, reservationId)); + Map.of("purchaseContextType", purchaseContext.getType().getUrlComponent(), + "purchaseContextIdentifier", purchaseContext.getPublicIdentifier(), + "reservationId", reservationId), new PaymentContext(purchaseContext, reservationId)); if(result.isSuccessful()) { return ResponseEntity.ok("OK"); } else if(result.isError()) { diff --git a/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java b/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java index c05b33ae4b..636ed650b3 100644 --- a/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java +++ b/src/main/java/alfio/controller/payment/api/stripe/StripePaymentWebhookController.java @@ -17,7 +17,6 @@ package alfio.controller.payment.api.stripe; import alfio.manager.TicketReservationManager; -import alfio.model.transaction.PaymentMethod; import alfio.model.transaction.PaymentProxy; import alfio.util.RequestUtils; import lombok.AllArgsConstructor; diff --git a/src/main/java/alfio/controller/support/Formatters.java b/src/main/java/alfio/controller/support/Formatters.java index 7e502d112d..a620236884 100644 --- a/src/main/java/alfio/controller/support/Formatters.java +++ b/src/main/java/alfio/controller/support/Formatters.java @@ -18,6 +18,7 @@ import alfio.model.ContentLanguage; import alfio.model.Event; +import alfio.model.LocalizedContent; import alfio.util.MustacheCustomTag; import lombok.experimental.UtilityClass; import lombok.extern.log4j.Log4j2; @@ -30,14 +31,16 @@ import java.util.List; import java.util.Map; import java.util.function.BiConsumer; -import java.util.function.Supplier; @UtilityClass @Log4j2 public class Formatters { - public static Map getFormattedDate(Event event, ZonedDateTime date, String code, MessageSource messageSource) { - return getFormattedDate(event.getContentLanguages(), date, code, messageSource); + public static Map getFormattedDate(LocalizedContent localizedContent, ZonedDateTime date, String code, MessageSource messageSource) { + if(localizedContent != null && date != null) { + return getFormattedDate(localizedContent.getContentLanguages(), date, code, messageSource); + } + return null; } private static Map getFormattedDate(List languages, ZonedDateTime date, String code, MessageSource messageSource) { diff --git a/src/main/java/alfio/controller/support/TemplateProcessor.java b/src/main/java/alfio/controller/support/TemplateProcessor.java index c16bc9ca71..3f923c484c 100644 --- a/src/main/java/alfio/controller/support/TemplateProcessor.java +++ b/src/main/java/alfio/controller/support/TemplateProcessor.java @@ -100,9 +100,9 @@ public static void renderPDFTicket(Locale language, renderToPdf(page, os, extensionManager, event); } - public static void renderToPdf(String page, OutputStream os, ExtensionManager extensionManager, Event event) throws IOException { + public static void renderToPdf(String page, OutputStream os, ExtensionManager extensionManager, PurchaseContext purchaseContext) throws IOException { - if(extensionManager.handlePdfTransformation(page, event, os)) { + if(extensionManager.handlePdfTransformation(page, purchaseContext, os)) { return; } PdfRendererBuilder builder = new PdfRendererBuilder(); @@ -163,9 +163,9 @@ public static class TemplateAccessException extends IllegalStateException { } } - public static Optional extractImageModel(Event event, FileUploadManager fileUploadManager) { - if(event.getFileBlobIdIsPresent()) { - return fileUploadManager.findMetadata(event.getFileBlobId()).map(metadata -> { + public static Optional extractImageModel(PurchaseContext purchaseContext, FileUploadManager fileUploadManager) { + if(purchaseContext.getFileBlobIdIsPresent()) { + return fileUploadManager.findMetadata(purchaseContext.getFileBlobId()).map(metadata -> { ByteArrayOutputStream baos = new ByteArrayOutputStream(); fileUploadManager.outputFile(metadata.getId(), baos); return TemplateResource.fillWithImageData(metadata, baos.toByteArray()); @@ -175,7 +175,7 @@ public static Optional extractImageModel(Event event } } - public static boolean buildReceiptOrInvoicePdf(Event event, + public static boolean buildReceiptOrInvoicePdf(PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, @@ -184,37 +184,37 @@ public static boolean buildReceiptOrInvoicePdf(Event event, ExtensionManager extensionManager, OutputStream os) { try { - String html = renderReceiptOrInvoicePdfTemplate(event, fileUploadManager, language, templateManager, model, templateResource); - renderToPdf(html, os, extensionManager, event); + String html = renderReceiptOrInvoicePdfTemplate(purchaseContext, fileUploadManager, language, templateManager, model, templateResource); + renderToPdf(html, os, extensionManager, purchaseContext); return true; } catch (IOException ioe) { return false; } } - public static String renderReceiptOrInvoicePdfTemplate(Event event, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, TemplateResource templateResource) { - extractImageModel(event, fileUploadManager).ifPresent(imageData -> { + public static String renderReceiptOrInvoicePdfTemplate(PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, TemplateResource templateResource) { + extractImageModel(purchaseContext, fileUploadManager).ifPresent(imageData -> { model.put("eventImage", imageData.getEventImage()); model.put("imageWidth", imageData.getImageWidth()); model.put("imageHeight", imageData.getImageHeight()); }); - return templateManager.renderTemplate(event, templateResource, model, language).getTextPart(); + return templateManager.renderTemplate(purchaseContext, templateResource, model, language).getTextPart(); } - public static Optional buildBillingDocumentPdf(BillingDocument.Type documentType, Event event, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, ExtensionManager extensionManager) { + public static Optional buildBillingDocumentPdf(BillingDocument.Type documentType, PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, ExtensionManager extensionManager) { switch (documentType) { case INVOICE: - return buildInvoicePdf(event, fileUploadManager, language, templateManager, model, extensionManager); + return buildInvoicePdf(purchaseContext, fileUploadManager, language, templateManager, model, extensionManager); case RECEIPT: - return buildReceiptPdf(event, fileUploadManager, language, templateManager, model, extensionManager); + return buildReceiptPdf(purchaseContext, fileUploadManager, language, templateManager, model, extensionManager); case CREDIT_NOTE: - return buildCreditNotePdf(event, fileUploadManager, language, templateManager, model, extensionManager); + return buildCreditNotePdf(purchaseContext, fileUploadManager, language, templateManager, model, extensionManager); default: throw new IllegalStateException(documentType + " not supported"); } } - private static Optional buildFrom(Event event, + private static Optional buildFrom(PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, @@ -222,34 +222,34 @@ private static Optional buildFrom(Event event, TemplateResource templateResource, ExtensionManager extensionManager) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); - boolean res = buildReceiptOrInvoicePdf(event, fileUploadManager, language, templateManager, model, templateResource, extensionManager, baos); + boolean res = buildReceiptOrInvoicePdf(purchaseContext, fileUploadManager, language, templateManager, model, templateResource, extensionManager, baos); return res ? Optional.of(baos.toByteArray()) : Optional.empty(); } - public static Optional buildReceiptPdf(Event event, + public static Optional buildReceiptPdf(PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, ExtensionManager extensionManager) { - return buildFrom(event, fileUploadManager, language, templateManager, model, TemplateResource.RECEIPT_PDF, extensionManager); + return buildFrom(purchaseContext, fileUploadManager, language, templateManager, model, TemplateResource.RECEIPT_PDF, extensionManager); } - public static Optional buildInvoicePdf(Event event, + public static Optional buildInvoicePdf(PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, ExtensionManager extensionManager) { - return buildFrom(event, fileUploadManager, language, templateManager, model, TemplateResource.INVOICE_PDF, extensionManager); + return buildFrom(purchaseContext, fileUploadManager, language, templateManager, model, TemplateResource.INVOICE_PDF, extensionManager); } - public static Optional buildCreditNotePdf(Event event, + public static Optional buildCreditNotePdf(PurchaseContext purchaseContext, FileUploadManager fileUploadManager, Locale language, TemplateManager templateManager, Map model, ExtensionManager extensionManager) { - return buildFrom(event, fileUploadManager, language, templateManager, model, TemplateResource.CREDIT_NOTE_PDF, extensionManager); + return buildFrom(purchaseContext, fileUploadManager, language, templateManager, model, TemplateResource.CREDIT_NOTE_PDF, extensionManager); } } diff --git a/src/main/java/alfio/extension/ExtensionService.java b/src/main/java/alfio/extension/ExtensionService.java index 9ce55fc0db..45aa1851a7 100644 --- a/src/main/java/alfio/extension/ExtensionService.java +++ b/src/main/java/alfio/extension/ExtensionService.java @@ -211,7 +211,7 @@ public Optional getSingle(String path, String name) { @Transactional(readOnly = true) public Optional getSingle(Organization organization, EventAndOrganizationId event, String name) { - Set paths = generatePossiblePath(ExtensionManager.toPath(organization.getId(), event.getId()), Comparator.reverseOrder()); + Set paths = generatePossiblePath(ExtensionManager.toPath(new EventAndOrganizationId(organization.getId(), event.getId())), Comparator.reverseOrder()); return extensionRepository.getSingle(paths, name); } @@ -230,7 +230,7 @@ public T executeScriptsForEvent(String event, String basePath, Map getScript(path, name)+"\n;GSON.fromJson(JSON.stringify(executeScript(extensionEvent)), returnClass);", input, clazz, extLogger); + () -> getScript(path, name)+"\n;var res = executeScript(extensionEvent); if(ExtensionUtils.needsJsonSerialization(res)) { res = GSON.fromJson(JSON.stringify(res), returnClass); }; res;", input, clazz, extLogger); input.put("output", res); } else { extLogger.logInfo("script not run, missing parameters: " + params.getLeft()); diff --git a/src/main/java/alfio/extension/ExtensionUtils.java b/src/main/java/alfio/extension/ExtensionUtils.java index 83ba307070..7e9d31e0c7 100644 --- a/src/main/java/alfio/extension/ExtensionUtils.java +++ b/src/main/java/alfio/extension/ExtensionUtils.java @@ -61,4 +61,17 @@ public static String formatDateTime(ZonedDateTime dateTime, String formatPattern public static String base64UrlSafe(String input) { return Base64.getUrlEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_8)); } + + /** + * Checks whether or not the object needs to be JSON-serialized + * in order to be returned as result + * + * @param returnObject the Object result + * @return + */ + public static boolean needsJsonSerialization(Object returnObject) { + return returnObject != null + // everything that is not included in the java.* package should be JSON-Serialized + && !returnObject.getClass().getPackage().getName().startsWith("java."); + } } diff --git a/src/main/java/alfio/extension/ScriptingExecutionService.java b/src/main/java/alfio/extension/ScriptingExecutionService.java index 5270ce20c6..bfb2d59e47 100644 --- a/src/main/java/alfio/extension/ScriptingExecutionService.java +++ b/src/main/java/alfio/extension/ScriptingExecutionService.java @@ -49,7 +49,6 @@ @Log4j2 public class ScriptingExecutionService { - private final SimpleHttpClient simpleHttpClient; private final Supplier executorSupplier; private final ScriptableObject sealedScope; @@ -67,7 +66,7 @@ public class ScriptingExecutionService { } public ScriptingExecutionService(HttpClient httpClient, Supplier executorSupplier) { - this.simpleHttpClient = new SimpleHttpClient(httpClient); + var simpleHttpClient = new SimpleHttpClient(httpClient); this.executorSupplier = executorSupplier; Context cx = ContextFactory.getGlobal().enterContext(); try { diff --git a/src/main/java/alfio/extension/support/SandboxContextFactory.java b/src/main/java/alfio/extension/support/SandboxContextFactory.java index 5a4992a5f8..0302ca6a66 100644 --- a/src/main/java/alfio/extension/support/SandboxContextFactory.java +++ b/src/main/java/alfio/extension/support/SandboxContextFactory.java @@ -17,7 +17,10 @@ package alfio.extension.support; import alfio.extension.exception.ExecutionTimeoutException; -import org.mozilla.javascript.*; +import org.mozilla.javascript.Callable; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.Scriptable; // source: https://codeutopia.net/blog/2009/01/02/sandboxing-rhino-in-java/ // https://www-archive.mozilla.org/rhino/apidocs/org/mozilla/javascript/contextfactory @@ -49,8 +52,8 @@ protected void observeInstructionCount(Context cx, int instructionCount) { MyContext mcx = (MyContext)cx; long currentTime = System.currentTimeMillis(); long executionTime = currentTime - mcx.startTime; - if (executionTime > 5*1000) { - // More than 5 seconds from Context creation time: + if (executionTime > 15*1000) { + // More than 15 seconds from Context creation time: // it is time to stop the script. // Throw Error instance to ensure that script will never // get control back through catch or finally. diff --git a/src/main/java/alfio/manager/AdminReservationManager.java b/src/main/java/alfio/manager/AdminReservationManager.java index 78422500e8..fd9592f76f 100644 --- a/src/main/java/alfio/manager/AdminReservationManager.java +++ b/src/main/java/alfio/manager/AdminReservationManager.java @@ -22,6 +22,7 @@ import alfio.manager.support.DuplicateReferenceException; import alfio.manager.system.ReservationPriceCalculator; import alfio.model.*; +import alfio.model.PurchaseContext.PurchaseContextType; import alfio.model.TicketReservation.TicketReservationStatus; import alfio.model.decorator.TicketPriceContainer; import alfio.model.metadata.AlfioMetadata; @@ -84,6 +85,7 @@ public class AdminReservationManager { private static final EnumSet UPDATE_INVOICE_STATUSES = EnumSet.of(TicketReservationStatus.OFFLINE_PAYMENT, TicketReservationStatus.PENDING); + private final PurchaseContextManager purchaseContextManager; private final EventManager eventManager; private final TicketReservationManager ticketReservationManager; private final TicketCategoryRepository ticketCategoryRepository; @@ -110,18 +112,15 @@ public class AdminReservationManager { private final ClockProvider clockProvider; //the following methods have an explicit transaction handling, therefore the @Transactional annotation is not helpful here - public Result, Event>> confirmReservation(String eventName, String reservationId, String username, Notification notification) { + public Result, PurchaseContext>> confirmReservation(PurchaseContextType purchaseContextType, String eventName, String reservationId, String username, Notification notification) { DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); TransactionTemplate template = new TransactionTemplate(transactionManager, definition); return template.execute(status -> { try { - Result, Event>> result = eventRepository.findOptionalByShortName(eventName) - .flatMap(e -> optionally(() -> { - eventManager.checkOwnership(e, username, e.getOrganizationId()); - return e; - })).map(event -> ticketReservationRepository.findOptionalReservationById(reservationId) + 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, event, r, notification, username)) + .map(r -> performConfirmation(reservationId, purchaseContext, r, notification, username)) .orElseGet(() -> Result.error(ErrorCode.ReservationError.UPDATE_FAILED)) ).orElseGet(() -> Result.error(ErrorCode.ReservationError.NOT_FOUND)); if(!result.isSuccess()) { @@ -137,26 +136,23 @@ public Result, Event>> confirmReservation }); } - public Result updateReservation(String eventName, String reservationId, AdminReservationModification adminReservationModification, String username) { + public Result updateReservation(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, AdminReservationModification adminReservationModification, String username) { DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); TransactionTemplate template = new TransactionTemplate(transactionManager, definition); return template.execute(status -> { try { - Result result = eventRepository.findOptionalByShortName(eventName) - .flatMap(e -> optionally(() -> { - eventManager.checkOwnership(e, username, e.getOrganizationId()); - return e; - })).map(event -> ticketReservationRepository.findOptionalReservationById(reservationId) + Result result = purchaseContextManager.findBy(purchaseContextType, publicIdentifier) + .map(event -> ticketReservationRepository.findOptionalReservationById(reservationId) .map(r -> performUpdate(reservationId, event, r, adminReservationModification, username)) .orElseGet(() -> Result.error(ErrorCode.ReservationError.UPDATE_FAILED)) ).orElseGet(() -> Result.error(ErrorCode.ReservationError.NOT_FOUND)); if(!result.isSuccess()) { - log.debug("Application error detected eventName: {} reservationId: {}, username: {}, reservation: {}", eventName, reservationId, username, AdminReservationModification.summary(adminReservationModification)); + log.debug("Application error detected eventName: {} reservationId: {}, username: {}, reservation: {}", publicIdentifier, reservationId, username, AdminReservationModification.summary(adminReservationModification)); status.setRollbackOnly(); } return result; } catch (Exception e) { - log.error("Error during update of reservation eventName: {} reservationId: {}, username: {}, reservation: {}", eventName, reservationId, username, AdminReservationModification.summary(adminReservationModification)); + log.error("Error during update of reservation eventName: {} reservationId: {}, username: {}, reservation: {}", publicIdentifier, reservationId, username, AdminReservationModification.summary(adminReservationModification)); status.setRollbackOnly(); return Result.error(singletonList(ErrorCode.custom("", e.getMessage()))); } @@ -190,9 +186,9 @@ public Result>> createReservation(AdminRese @Transactional public Result notifyAttendees(String eventName, String reservationId, List ids, String username) { - return getEventTicketReservationPair(eventName, reservationId, username) + return getEventTicketReservationPair(PurchaseContextType.event, eventName, reservationId, username) .map(pair -> { - Event event = pair.getLeft(); + Event event = pair.getLeft().event().orElseThrow(); TicketReservation reservation = pair.getRight(); sendTicketToAttendees(event, reservation, t -> t.getAssigned() && ids.contains(t.getId())); return Result.success(true); @@ -200,29 +196,26 @@ public Result notifyAttendees(String eventName, String reservationId, L } @Transactional - public Result notify(String eventName, String reservationId, AdminReservationModification arm, String username) { + public Result notify(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, AdminReservationModification arm, String username) { Notification notification = arm.getNotification(); - return getEventTicketReservationPair(eventName, reservationId, username) + return getEventTicketReservationPair(purchaseContextType, publicIdentifier, reservationId, username) .map(pair -> { - Event event = pair.getLeft(); + var purchaseContext = pair.getLeft(); TicketReservation reservation = pair.getRight(); if(notification.isCustomer()){ - ticketReservationManager.sendConfirmationEmail(event, reservation, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), username); + ticketReservationManager.sendConfirmationEmail(purchaseContext, reservation, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), username); } - if(notification.isAttendees()) { - sendTicketToAttendees(event, reservation, Ticket::getAssigned); + if(notification.isAttendees() && purchaseContextType == PurchaseContextType.event) { + sendTicketToAttendees(purchaseContext.event().orElseThrow(), reservation, Ticket::getAssigned); } return Result.success(true); }).orElseGet(() -> Result.error(ErrorCode.EventError.NOT_FOUND)); } - private Optional> getEventTicketReservationPair(String eventName, String reservationId, String username) { - return eventRepository.findOptionalByShortName(eventName) - .flatMap(e -> optionally(() -> { - eventManager.checkOwnership(e, username, e.getOrganizationId()); - return e; - }).flatMap(ev -> ticketReservationRepository.findOptionalReservationById(reservationId).map(r -> Pair.of(e, r)))); + private Optional> getEventTicketReservationPair(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, String username) { + return purchaseContextManager.findBy(purchaseContextType, publicIdentifier) + .flatMap(ev -> ticketReservationRepository.findOptionalReservationById(reservationId).map(r -> Pair.of(ev, r))); } private void sendTicketToAttendees(Event event, TicketReservation reservation, Predicate matcher) { @@ -235,9 +228,9 @@ private void sendTicketToAttendees(Event event, TicketReservation reservation, P }); } - private Result performUpdate(String reservationId, Event event, TicketReservation r, AdminReservationModification arm, String username) { - billingDocumentManager.ensureBillingDocumentIsPresent(event, r, username, () -> ticketReservationManager.orderSummaryForReservationId(reservationId, event)); - ticketReservationRepository.updateValidity(reservationId, Date.from(arm.getExpiration().toZonedDateTime(event.getZoneId()).toInstant())); + private Result performUpdate(String reservationId, PurchaseContext purchaseContext, TicketReservation r, AdminReservationModification arm, String username) { + billingDocumentManager.ensureBillingDocumentIsPresent(purchaseContext, r, username, () -> ticketReservationManager.orderSummaryForReservationId(reservationId, purchaseContext)); + ticketReservationRepository.updateValidity(reservationId, Date.from(arm.getExpiration().toZonedDateTime(purchaseContext.getZoneId()).toInstant())); if(arm.isUpdateContactData()) { AdminReservationModification.CustomerData customerData = arm.getCustomerData(); ticketReservationRepository.updateTicketReservation(reservationId, r.getStatus().name(), customerData.getEmailAddress(), @@ -255,17 +248,17 @@ private Result performUpdate(String reservationId, Event event, TicketR } - if(arm.isUpdateAdvancedBillingOptions() && event.getVatStatus() != PriceContainer.VatStatus.NONE) { + if(arm.isUpdateAdvancedBillingOptions() && purchaseContext.getVatStatus() != PriceContainer.VatStatus.NONE) { boolean vatApplicationRequested = arm.getAdvancedBillingOptions().isVatApplied(); PriceContainer.VatStatus newVatStatus; if(vatApplicationRequested) { - newVatStatus = event.getVatStatus(); + newVatStatus = purchaseContext.getVatStatus(); } else { - newVatStatus = event.getVatStatus() == PriceContainer.VatStatus.INCLUDED ? PriceContainer.VatStatus.INCLUDED_EXEMPT : PriceContainer.VatStatus.NOT_INCLUDED_EXEMPT; + newVatStatus = purchaseContext.getVatStatus() == PriceContainer.VatStatus.INCLUDED ? PriceContainer.VatStatus.INCLUDED_EXEMPT : PriceContainer.VatStatus.NOT_INCLUDED_EXEMPT; } - if(newVatStatus != ObjectUtils.firstNonNull(r.getVatStatus(), event.getVatStatus())) { - auditingRepository.insert(reservationId, userRepository.getByUsername(username).getId(), event.getId(), Audit.EventType.FORCE_VAT_APPLICATION, new Date(), Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("vatStatus", newVatStatus))); + if(newVatStatus != ObjectUtils.firstNonNull(r.getVatStatus(), purchaseContext.getVatStatus())) { + auditingRepository.insert(reservationId, userRepository.getByUsername(username).getId(), purchaseContext, Audit.EventType.FORCE_VAT_APPLICATION, new Date(), Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("vatStatus", newVatStatus))); ticketReservationRepository.addReservationInvoiceOrReceiptModel(reservationId, null); var newPrice = ticketReservationManager.totalReservationCostWithVAT(r.withVatStatus(newVatStatus)).getLeft(); ticketReservationRepository.resetVat(reservationId, r.isInvoiceRequested(), newVatStatus, r.getSrcPriceCts(), newPrice.getPriceWithVAT(), @@ -288,33 +281,32 @@ private Result performUpdate(String reservationId, Event event, TicketR modifications.put("firstName", firstName); modifications.put("lastName", lastName); modifications.put("fullName", fullName); - auditingRepository.insert(reservationId, userId, event.getId(), UPDATE_TICKET, d, TICKET, Integer.toString(a.getTicketId()), singletonList(modifications)); + auditingRepository.insert(reservationId, userId, purchaseContext, UPDATE_TICKET, d, TICKET, Integer.toString(a.getTicketId()), singletonList(modifications)); }); return Result.success(true); } @Transactional - public Result, Event>> loadReservation(String eventName, String reservationId, String username) { - return eventRepository.findOptionalByShortName(eventName) - .flatMap(e -> optionally(() -> { - eventManager.checkOwnership(e, username, e.getOrganizationId()); - return e; - })).map(r -> loadReservation(reservationId)) + public Result, PurchaseContext>> loadReservation(PurchaseContextType purchaseContextType, + String publicIdentifier, + String reservationId, + String username) { + return purchaseContextManager.findBy(purchaseContextType, publicIdentifier).map(r -> loadReservation(reservationId)) .orElseGet(() -> Result.error(ErrorCode.ReservationError.NOT_FOUND)); } - private Result, Event>> loadReservation(String reservationId) { + private Result, PurchaseContext>> loadReservation(String reservationId) { return ticketReservationRepository.findOptionalReservationById(reservationId) - .map(r -> Triple.of(r, ticketRepository.findTicketsInReservation(reservationId), eventRepository.findByReservationId(reservationId))) + .map(r -> Triple.of(r, ticketRepository.findTicketsInReservation(reservationId), purchaseContextManager.findByReservationId(reservationId).orElseThrow())) .map(Result::success) .orElseGet(() -> Result.error(ErrorCode.ReservationError.NOT_FOUND)); } - private Result, Event>> performConfirmation(String reservationId, Event event, TicketReservation original, Notification notification, String username) { + private Result, PurchaseContext>> performConfirmation(String reservationId, PurchaseContext purchaseContext, TicketReservation original, Notification notification, String username) { try { PaymentSpecification spec = new PaymentSpecification(reservationId, null, 0, - event, original.getEmail(), new CustomerName(original.getFullName(), original.getFirstName(), original.getLastName(), event.mustUseFirstAndLastName()), + purchaseContext, original.getEmail(), new CustomerName(original.getFullName(), original.getFirstName(), original.getLastName(), purchaseContext.mustUseFirstAndLastName()), original.getBillingAddress(), original.getCustomerReference(), LocaleUtil.forLanguageTag(original.getUserLanguage()), false, false, null, null, null, null, false, false); @@ -376,7 +368,7 @@ private Result>> createReservation(Result ticketIds, List toRefund, boolean notify, boolean forceInvoiceReceiptUpdate, String username) { - loadReservation(eventName, reservationId, username).ifSuccess(res -> { - Event e = res.getRight(); + public void removeTickets(String publicIdentifier, String reservationId, List ticketIds, List toRefund, boolean notify, boolean forceInvoiceReceiptUpdate, String username) { + loadReservation(PurchaseContextType.event, publicIdentifier, reservationId, username).ifSuccess(res -> { + Event e = res.getRight().event().orElseThrow(); TicketReservation reservation = res.getLeft(); List tickets = res.getMiddle(); Map ticketsById = tickets.stream().collect(Collectors.toMap(Ticket::getId, Function.identity())); @@ -586,7 +578,7 @@ public void removeTickets(String eventName, String reservationId, List handleTicketsRefund(toRefund, e, reservation, ticketsById, username); if(removeReservation) { - markAsCancelled(reservation, username, e.getId()); + markAsCancelled(reservation, username, e); additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservation.getId(), AdditionalServiceItem.AdditionalServiceItemStatus.CANCELLED); } else { // recalculate totals @@ -595,7 +587,7 @@ public void removeTickets(String eventName, String reservationId, List var updatedTickets = ticketRepository.findTicketsInReservation(reservationId); var discount = reservation.getPromoCodeDiscountId() != null ? promoCodeDiscountRepository.findById(reservation.getPromoCodeDiscountId()) : null; List additionalServiceItems = additionalServiceItemRepository.findByReservationUuid(reservationId); - var calculator = new ReservationPriceCalculator(reservation, discount, updatedTickets, additionalServiceItems, additionalServiceRepository.loadAllForEvent(e.getId()), e); + var calculator = new ReservationPriceCalculator(reservation, discount, updatedTickets, additionalServiceItems, additionalServiceRepository.loadAllForEvent(e.getId()), e, List.of(), Optional.empty()); ticketReservationRepository.updateBillingData(calculator.getVatStatus(), calculator.getSrcPriceCts(), unitToCents(calculator.getFinalPrice(), currencyCode), unitToCents(calculator.getVAT(), currencyCode), unitToCents(calculator.getAppliedDiscount(), currencyCode), calculator.getCurrencyCode(), reservation.getVatNr(), reservation.getVatCountryCode(), @@ -605,39 +597,43 @@ public void removeTickets(String eventName, String reservationId, List } @Transactional(readOnly = true) - public Result> getAudit(String eventName, String reservationId, String username) { - return loadReservation(eventName, reservationId, username).map(res -> auditingRepository.findAllForReservation(reservationId)); + public Result> getAudit(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, String username) { + // FIXME modify query in order to validate if reservation is present for the PurchaseContext + return Result.success(auditingRepository.findAllForReservation(reservationId)); } @Transactional(readOnly = true) public Result> getBillingDocuments(String eventName, String reservationId, String username) { - return loadReservation(eventName, reservationId, username).map(res -> billingDocumentRepository.findAllByReservationId(reservationId)); + // FIXME modify query in order to validate if reservation is present for the PurchaseContext + return Result.success(billingDocumentRepository.findAllByReservationId(reservationId)); } @Transactional(readOnly = true) - public Result> getSingleBillingDocumentAsPdf(String eventName, String reservationId, long documentId, String username) { - return loadReservation(eventName, reservationId, username).map(res -> { - BillingDocument billingDocument = billingDocumentRepository.findByIdAndReservationId(documentId, reservationId).orElseThrow(IllegalArgumentException::new); - Function, Optional> pdfGenerator = model -> TemplateProcessor.buildBillingDocumentPdf(billingDocument.getType(), res.getRight(), fileUploadManager, LocaleUtil.forLanguageTag(res.getLeft().getUserLanguage()), templateManager, model, extensionManager); - Map billingModel = billingDocument.getModel(); - return Pair.of(billingDocument, pdfGenerator.apply(billingModel).orElse(null)); - }); + public Result> getSingleBillingDocumentAsPdf(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, long documentId, String username) { + // FIXME modify query in order to validate if reservation is present for the PurchaseContext + return loadReservation(purchaseContextType, publicIdentifier, reservationId, username) + .map(res -> { + BillingDocument billingDocument = billingDocumentRepository.findByIdAndReservationId(documentId, reservationId).orElseThrow(IllegalArgumentException::new); + Function, Optional> pdfGenerator = model -> TemplateProcessor.buildBillingDocumentPdf(billingDocument.getType(), res.getRight(), fileUploadManager, LocaleUtil.forLanguageTag(res.getLeft().getUserLanguage()), templateManager, model, extensionManager); + Map billingModel = billingDocument.getModel(); + return Pair.of(billingDocument, pdfGenerator.apply(billingModel).orElse(null)); + }); } @Transactional - public Result invalidateBillingDocument(String eventName, String reservationId, long documentId, String username) { - return updateBillingDocumentStatus(eventName, reservationId, documentId, username, BillingDocument.Status.NOT_VALID, Audit.EventType.BILLING_DOCUMENT_INVALIDATED); + public Result invalidateBillingDocument(String reservationId, long documentId, String username) { + return updateBillingDocumentStatus(reservationId, documentId, username, BillingDocument.Status.NOT_VALID, Audit.EventType.BILLING_DOCUMENT_INVALIDATED); } @Transactional - public Result restoreBillingDocument(String eventName, String reservationId, long documentId, String username) { - return updateBillingDocumentStatus(eventName, reservationId, documentId, username, BillingDocument.Status.VALID, Audit.EventType.BILLING_DOCUMENT_RESTORED); + public Result restoreBillingDocument(String reservationId, long documentId, String username) { + return updateBillingDocumentStatus(reservationId, documentId, username, BillingDocument.Status.VALID, Audit.EventType.BILLING_DOCUMENT_RESTORED); } - private Result updateBillingDocumentStatus(String eventName, String reservationId, long documentId, String username, BillingDocument.Status status, Audit.EventType eventType) { - return loadReservation(eventName, reservationId, username).map(res -> { + private Result updateBillingDocumentStatus(String reservationId, long documentId, String username, BillingDocument.Status status, Audit.EventType eventType) { + return loadReservation(reservationId).map(res -> { Integer userId = userRepository.findIdByUserName(username).orElse(null); - auditingRepository.insert(reservationId, userId, res.getRight().getId(), eventType, new Date(), RESERVATION, String.valueOf(documentId)); + auditingRepository.insert(reservationId, userId, res.getRight(), eventType, new Date(), RESERVATION, String.valueOf(documentId)); return billingDocumentRepository.updateStatus(documentId, status, reservationId) == 1; }); @@ -645,40 +641,40 @@ private Result updateBillingDocumentStatus(String eventName, String res } @Transactional - public Result getPaymentInfo(String eventName, String reservationId, String username) { - return loadReservation(eventName, reservationId, username) + public Result getPaymentInfo(String reservationId) { + return loadReservation(reservationId) .map(res -> paymentManager.getInfo(res.getLeft(), res.getRight())); } @Transactional - public Result removeReservation(String eventName, String reservationId, boolean refund, boolean notify, String username) { - return removeReservation(eventName, reservationId, refund, notify, username, true) + public Result removeReservation(PurchaseContextType purchaseContextType, String eventName, String reservationId, boolean refund, boolean notify, String username) { + return removeReservation(purchaseContextType, eventName, reservationId, refund, notify, username, true) .map(pair -> { - markAsCancelled(pair.getRight(), username, pair.getLeft().getId()); + markAsCancelled(pair.getRight(), username, pair.getLeft()); return true; }); } @Transactional - public void creditReservation(String eventName, String reservationId, boolean refund, boolean notify, String username) { - removeReservation(eventName, reservationId, refund, notify, username, false) + public void creditReservation(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, boolean refund, boolean notify, String username) { + removeReservation(purchaseContextType, publicIdentifier, reservationId, refund, notify, username, false) .ifSuccess(pair -> ticketReservationManager.issueCreditNoteForReservation(pair.getLeft(), pair.getRight().getId(), username)); } - private Result> removeReservation(String eventName, String reservationId, boolean refund, boolean notify, String username, boolean removeReservation) { - return loadReservation(eventName, reservationId, username).flatMap(res -> { - Event e = res.getRight(); + private Result> removeReservation(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, boolean refund, boolean notify, String username, boolean removeReservation) { + return loadReservation(purchaseContextType, publicIdentifier, reservationId, username).flatMap(res -> { + var purchaseContext = res.getRight(); TicketReservation reservation = res.getLeft(); List tickets = res.getMiddle(); var checkedInTicketsCount = tickets.stream().filter(c -> c.getStatus() == Ticket.TicketStatus.CHECKED_IN).count(); - if ( checkedInTicketsCount > 0) { + if (checkedInTicketsCount > 0) { return Result.error(ErrorCode.custom("remove-reservation.failed", checkedInTicketsCount +" tickets are already checked-in for this reservation!! Unable to cancel the reservation.")); } if(refund && reservation.getPaymentMethod() != null && reservation.getPaymentMethod().isSupportRefund()) { //fully refund - boolean refundResult = paymentManager.refund(reservation, e, null, username); + boolean refundResult = paymentManager.refund(reservation, purchaseContext, null, username); if(!refundResult) { return Result.error(ErrorCode.custom("refund.failed", "Cannot perform refund")); } @@ -686,28 +682,29 @@ private Result> removeReservation(String eventNam specialPriceRepository.resetToFreeAndCleanupForReservation(List.of(reservationId)); - removeTicketsFromReservation(reservation, e, tickets.stream().map(Ticket::getId).collect(toList()), notify, username, removeReservation, false); + if(purchaseContext.getType() == PurchaseContextType.event) { + removeTicketsFromReservation(reservation, purchaseContext.event().orElseThrow(), tickets.stream().map(Ticket::getId).collect(toList()), notify, username, removeReservation, false); + } additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservation.getId(), AdditionalServiceItem.AdditionalServiceItemStatus.CANCELLED); - return Result.success(Pair.of(e, reservation)); + return Result.success(Pair.of(purchaseContext, reservation)); }); } @Transactional - public Result refund(String eventName, String reservationId, BigDecimal refundAmount, String username) { - return loadReservation(eventName, reservationId, username).map(res -> { - Event e = res.getRight(); + public Result refund(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, BigDecimal refundAmount, String username) { + return loadReservation(purchaseContextType, publicIdentifier, reservationId, username).map(res -> { TicketReservation reservation = res.getLeft(); return reservation.getPaymentMethod() != null && reservation.getPaymentMethod().isSupportRefund() - && paymentManager.refund(reservation, e, unitToCents(refundAmount, reservation.getCurrencyCode()), username); + && paymentManager.refund(reservation, res.getRight(), unitToCents(refundAmount, reservation.getCurrencyCode()), username); }); } @Transactional - public Result regenerateBillingDocument(String eventName, String reservationId, String username) { - return loadReservation(eventName, reservationId, username).map(res -> { + public Result regenerateBillingDocument(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, String username) { + return loadReservation(purchaseContextType, publicIdentifier, reservationId, username).map(res -> { var event = res.getRight(); var reservation = res.getLeft(); billingDocumentManager.createBillingDocument(event, reservation, username, ticketReservationManager.orderSummaryForReservation(reservation, event)); @@ -716,9 +713,9 @@ public Result regenerateBillingDocument(String eventName, String reserv } - public Result> getEmailsForReservation(String eventName, String reservationId, String username) { - return loadReservation(eventName, reservationId, username) - .map(res -> notificationManager.loadAllMessagesForReservationId(res.getRight().getId(), reservationId)); + public Result> getEmailsForReservation(PurchaseContextType purchaseContextType, String publicIdentifier, String reservationId, String username) { + return loadReservation(purchaseContextType, publicIdentifier, reservationId, username) + .map(res -> notificationManager.loadAllMessagesForReservationId(res.getRight(), reservationId)); } private void removeTicketsFromReservation(TicketReservation reservation, Event event, List ticketIds, boolean notify, String username, boolean removeReservation, boolean forceInvoiceReceiptUpdate) { @@ -768,15 +765,15 @@ private void sendTicketHasBeenRemoved(Event event, Organization organization, Ti Map model = TemplateResource.buildModelForTicketHasBeenCancelled(organization, event, ticket); Locale locale = LocaleUtil.forLanguageTag(Optional.ofNullable(ticket.getUserLanguage()).orElse("en")); notificationManager.sendSimpleEmail(event, ticket.getTicketsReservationId(), ticket.getEmail(), - messageSourceManager.getMessageSourceForEvent(event).getMessage("email-ticket-released.subject", + messageSourceManager.getMessageSourceFor(event).getMessage("email-ticket-released.subject", new Object[]{event.getDisplayName()}, locale), () -> templateManager.renderTemplate(event, TemplateResource.TICKET_HAS_BEEN_CANCELLED, model, locale)); } - private void markAsCancelled(TicketReservation ticketReservation, String username, int eventId) { + private void markAsCancelled(TicketReservation ticketReservation, String username, PurchaseContext purchaseContext) { ticketReservationRepository.updateReservationStatus(ticketReservation.getId(), TicketReservationStatus.CANCELLED.toString()); auditingRepository.insert(ticketReservation.getId(), userRepository.nullSafeFindIdByUserName(username).orElse(null), - eventId, Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, ticketReservation.getId()); + purchaseContext, Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, ticketReservation.getId()); } private void handleTicketsRefund(List toRefund, Event e, TicketReservation reservation, Map ticketsById, String username) { diff --git a/src/main/java/alfio/manager/AdminReservationRequestManager.java b/src/main/java/alfio/manager/AdminReservationRequestManager.java index b5a67e24db..dc6baab2f8 100644 --- a/src/main/java/alfio/manager/AdminReservationRequestManager.java +++ b/src/main/java/alfio/manager/AdminReservationRequestManager.java @@ -136,7 +136,8 @@ private Result, Event>> processReservatio String eventName = event.getShortName(); String username = user.getUsername(); Result, Event>> result = adminReservationManager.createReservation(request.getBody(), eventName, username) - .flatMap(r -> adminReservationManager.confirmReservation(eventName, r.getLeft().getId(), username, orEmpty(request.getBody().getNotification()))); + .flatMap(r -> adminReservationManager.confirmReservation(PurchaseContext.PurchaseContextType.event, eventName, r.getLeft().getId(), username, orEmpty(request.getBody().getNotification()))) + .map(triple -> Triple.of(triple.getLeft(), triple.getMiddle(), (Event) triple.getRight())); if(!result.isSuccess()) { status.rollbackToSavepoint(savepoint); } diff --git a/src/main/java/alfio/manager/BillingDocumentManager.java b/src/main/java/alfio/manager/BillingDocumentManager.java index 559093736e..bfc68a93c2 100644 --- a/src/main/java/alfio/manager/BillingDocumentManager.java +++ b/src/main/java/alfio/manager/BillingDocumentManager.java @@ -16,10 +16,10 @@ */ package alfio.manager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.system.Mailer; import alfio.model.*; +import alfio.model.PurchaseContext.PurchaseContextType; import alfio.model.system.ConfigurationKeys; import alfio.model.user.Organization; import alfio.repository.*; @@ -75,7 +75,7 @@ static boolean mustGenerateBillingDocument(OrderSummary summary, TicketReservati return !summary.getFree() && (!summary.getNotYetPaid() || (summary.getWaitingForPayment() && ticketReservation.isInvoiceRequested())); } - List generateBillingDocumentAttachment(Event event, + List generateBillingDocumentAttachment(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, BillingDocument.Type documentType, @@ -83,9 +83,9 @@ List generateBillingDocumentAttachment(Event event, OrderSummary orderSummary) { Map model = new HashMap<>(); model.put("reservationId", ticketReservation.getId()); - model.put("eventId", Integer.toString(event.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(event, ticketReservation, username, orderSummary).getModel())); + model.put("reservationEmailModel", json.asJsonString(getOrCreateBillingDocument(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)); @@ -99,43 +99,44 @@ List generateBillingDocumentAttachment(Event event, } @Transactional - public void ensureBillingDocumentIsPresent(Event event, TicketReservation reservation, String username, Supplier orderSummarySupplier) { + public void ensureBillingDocumentIsPresent(PurchaseContext purchaseContext, TicketReservation reservation, String username, Supplier orderSummarySupplier) { if(reservation.getStatus() == PENDING || reservation.getStatus() == CANCELLED) { return; } var orderSummary = orderSummarySupplier.get(); if(mustGenerateBillingDocument(orderSummary, reservation)) { - getOrCreateBillingDocument(event, reservation, username, orderSummary); + getOrCreateBillingDocument(purchaseContext, reservation, username, orderSummary); } } @Transactional - public BillingDocument createBillingDocument(Event event, TicketReservation reservation, String username, OrderSummary orderSummary) { - return createBillingDocument(event, reservation, username, reservation.getHasInvoiceNumber() ? INVOICE : RECEIPT, orderSummary); + public BillingDocument createBillingDocument(PurchaseContext purchaseContext, TicketReservation reservation, String username, OrderSummary orderSummary) { + return createBillingDocument(purchaseContext, reservation, username, reservation.getHasInvoiceNumber() ? INVOICE : RECEIPT, orderSummary); } - BillingDocument createBillingDocument(Event event, TicketReservation reservation, String username, BillingDocument.Type type, OrderSummary orderSummary) { - Map model = prepareModelForBillingDocument(event, reservation, orderSummary); + BillingDocument createBillingDocument(PurchaseContext purchaseContext, TicketReservation reservation, String username, BillingDocument.Type type, OrderSummary orderSummary) { + Map model = prepareModelForBillingDocument(purchaseContext, reservation, orderSummary); String number = reservation.getHasInvoiceNumber() ? reservation.getInvoiceNumber() : UUID.randomUUID().toString(); - AffectedRowCountAndKey doc = billingDocumentRepository.insert(event.getId(), reservation.getId(), number, type, json.asJsonString(model), event.now(clockProvider), event.getOrganizationId()); - auditingRepository.insert(reservation.getId(), userRepository.nullSafeFindIdByUserName(username).orElse(null), event.getId(), Audit.EventType.BILLING_DOCUMENT_GENERATED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), singletonList(singletonMap("documentId", doc.getKey()))); + var eventId = purchaseContext.event().map(Event::getId).orElse(null); + AffectedRowCountAndKey doc = billingDocumentRepository.insert(eventId, reservation.getId(), number, type, json.asJsonString(model), purchaseContext.now(clockProvider), purchaseContext.getOrganizationId()); + auditingRepository.insert(reservation.getId(), userRepository.nullSafeFindIdByUserName(username).orElse(null), purchaseContext, Audit.EventType.BILLING_DOCUMENT_GENERATED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), singletonList(singletonMap("documentId", doc.getKey()))); return billingDocumentRepository.findByIdAndReservationId(doc.getKey(), reservation.getId()).orElseThrow(IllegalStateException::new); } @Transactional - public BillingDocument getOrCreateBillingDocument(Event event, TicketReservation reservation, String username, OrderSummary orderSummary) { + public BillingDocument getOrCreateBillingDocument(PurchaseContext purchaseContext, TicketReservation reservation, String username, OrderSummary orderSummary) { Optional existing = billingDocumentRepository.findLatestByReservationId(reservation.getId()); - return existing.orElseGet(() -> createBillingDocument(event, reservation, username, orderSummary)); + return existing.orElseGet(() -> createBillingDocument(purchaseContext, reservation, username, orderSummary)); } public Optional getDocumentById(long id) { return billingDocumentRepository.findById(id); } - private Map prepareModelForBillingDocument(Event event, TicketReservation reservation, OrderSummary summary) { - Organization organization = organizationRepository.getById(event.getOrganizationId()); + private Map prepareModelForBillingDocument(PurchaseContext purchaseContext, TicketReservation reservation, OrderSummary summary) { + Organization organization = organizationRepository.getById(purchaseContext.getOrganizationId()); - var bankingInfo = configurationManager.getFor(Set.of(VAT_NR, INVOICE_ADDRESS, BANK_ACCOUNT_NR, BANK_ACCOUNT_OWNER), ConfigurationLevel.event(event)); + var bankingInfo = configurationManager.getFor(Set.of(VAT_NR, INVOICE_ADDRESS, BANK_ACCOUNT_NR, BANK_ACCOUNT_OWNER), purchaseContext.getConfigurationLevel()); Optional invoiceAddress = bankingInfo.get(INVOICE_ADDRESS).getValue(); Optional bankAccountNr = bankingInfo.get(BANK_ACCOUNT_NR).getValue(); Optional bankAccountOwner = bankingInfo.get(BANK_ACCOUNT_OWNER).getValue(); @@ -153,12 +154,13 @@ private Map prepareModelForBillingDocument(Event event, TicketRe } else { ticketsWithCategory = Collections.emptyList(); } - Map model = TemplateResource.prepareModelForConfirmationEmail(organization, event, reservation, vat, ticketsWithCategory, summary, "", "", "", invoiceAddress, bankAccountNr, bankAccountOwner, Map.of()); + Map model = TemplateResource.prepareModelForConfirmationEmail(organization, purchaseContext, reservation, vat, ticketsWithCategory, summary, "", "", "", invoiceAddress, bankAccountNr, bankAccountOwner, Map.of()); 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("isEvent", purchaseContext.getType() == PurchaseContextType.event); model.put("euBusiness", euBusiness); - model.put("publicId", configurationManager.getPublicReservationID(event, reservation)); + model.put("publicId", configurationManager.getPublicReservationID(purchaseContext, reservation)); model.put("invoicingAdditionalInfo", ticketReservationRepository.getAdditionalInfo(reservation.getId()).getInvoicingAdditionalInfo()); return model; } diff --git a/src/main/java/alfio/manager/CheckInManager.java b/src/main/java/alfio/manager/CheckInManager.java index 064c950796..ce62589363 100644 --- a/src/main/java/alfio/manager/CheckInManager.java +++ b/src/main/java/alfio/manager/CheckInManager.java @@ -17,7 +17,6 @@ package alfio.manager; import alfio.manager.support.*; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.Ticket.TicketStatus; @@ -470,7 +469,7 @@ public Map getEncryptedAttendeesInformation(Event ev, Set } static CheckInOutputColorConfiguration getOutputColorConfiguration(EventAndOrganizationId event, ConfigurationManager configurationManager) { - return configurationManager.getFor(CHECK_IN_COLOR_CONFIGURATION, ConfigurationLevel.event(event)).getValue() + return configurationManager.getFor(CHECK_IN_COLOR_CONFIGURATION, event.getConfigurationLevel()).getValue() .flatMap(str -> optionally(() -> Json.fromJson(str, CheckInOutputColorConfiguration.class))) .orElse(null); } @@ -523,7 +522,7 @@ public CheckInStatistics getStatistics(String eventName, String username) { } private boolean areStatsEnabled(EventAndOrganizationId event) { - return configurationManager.getFor(CHECK_IN_STATS, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + return configurationManager.getFor(CHECK_IN_STATS, event.getConfigurationLevel()).getValueAsBooleanOrDefault(); } } diff --git a/src/main/java/alfio/manager/EuVatChecker.java b/src/main/java/alfio/manager/EuVatChecker.java index 7820d30525..bfcdf4bd6d 100644 --- a/src/main/java/alfio/manager/EuVatChecker.java +++ b/src/main/java/alfio/manager/EuVatChecker.java @@ -16,11 +16,8 @@ */ package alfio.manager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; -import alfio.model.Audit; -import alfio.model.EventAndOrganizationId; -import alfio.model.VatDetail; +import alfio.model.*; import alfio.model.system.ConfigurationKeys; import alfio.repository.AuditingRepository; import ch.digitalfondue.vatchecker.EUVatCheckResponse; @@ -57,16 +54,16 @@ public class EuVatChecker { .expireAfterWrite(Duration.ofMinutes(15)) .build(); - public boolean isReverseChargeEnabledFor(EventAndOrganizationId eventAndOrganizationId) { - return reverseChargeEnabled(configurationManager, eventAndOrganizationId); + public boolean isReverseChargeEnabledFor(Configurable configurable) { + return reverseChargeEnabled(configurationManager, configurable); } - public Optional checkVat(String vatNr, String countryCode, EventAndOrganizationId event) { - Optional res = performCheck(vatNr, countryCode, event).apply(configurationManager, client); + public Optional checkVat(String vatNr, String countryCode, PurchaseContext purchaseContext) { + Optional res = performCheck(vatNr, countryCode, purchaseContext).apply(configurationManager, client); return res.map(detail -> { if(!detail.isValid()) { - String organizerCountry = organizerCountry(configurationManager, event); - boolean valid = extensionManager.handleTaxIdValidation(event.getId(), vatNr, organizerCountry); + String organizerCountry = organizerCountry(configurationManager, purchaseContext); + boolean valid = extensionManager.handleTaxIdValidation(purchaseContext, vatNr, organizerCountry); return new VatDetail(detail.getVatNr(), detail.getCountry(), valid, detail.getName(), detail.getAddress(), VatDetail.Type.FORMAL, false); } else { return detail; @@ -74,7 +71,7 @@ public Optional checkVat(String vatNr, String countryCode, EventAndOr }); } - static BiFunction> performCheck(String vatNr, String countryCode, EventAndOrganizationId eventAndOrganizationId) { + static BiFunction> performCheck(String vatNr, String countryCode, Configurable configurable) { return (configurationManager, client) -> { boolean vatNrNotEmpty = StringUtils.isNotEmpty(vatNr); boolean validCountryCode = StringUtils.length(StringUtils.trimToNull(countryCode)) == 2; @@ -86,26 +83,26 @@ static BiFunction> perfo boolean euCountryCode = configurationManager.getForSystem(ConfigurationKeys.EU_COUNTRIES_LIST).getRequiredValue().contains(countryCode); - boolean validationEnabled = validationEnabled(configurationManager, eventAndOrganizationId); + boolean validationEnabled = validationEnabled(configurationManager, configurable); if(euCountryCode && validationEnabled) { EUVatCheckResponse validationResult = validateEUVat(vatNr, countryCode, client); return Optional.ofNullable(validationResult) - .map(r -> getVatDetail(reverseChargeEnabled(configurationManager, eventAndOrganizationId), r, vatNr, countryCode, organizerCountry(configurationManager, eventAndOrganizationId))); + .map(r -> getVatDetail(reverseChargeEnabled(configurationManager, configurable), r, vatNr, countryCode, organizerCountry(configurationManager, configurable))); } - String organizerCountry = organizerCountry(configurationManager, eventAndOrganizationId); - if(StringUtils.isEmpty(organizerCountry(configurationManager, eventAndOrganizationId))) { + String organizerCountry = organizerCountry(configurationManager, configurable); + if(StringUtils.isEmpty(organizerCountry(configurationManager, configurable))) { return Optional.empty(); } - BooleanSupplier applyVatToForeignBusiness = () -> configurationManager.getFor(APPLY_VAT_FOREIGN_BUSINESS, ConfigurationLevel.event(eventAndOrganizationId)).getValueAsBooleanOrDefault(); + BooleanSupplier applyVatToForeignBusiness = () -> configurationManager.getFor(APPLY_VAT_FOREIGN_BUSINESS, configurable.getConfigurationLevel()).getValueAsBooleanOrDefault(); boolean vatExempt = !organizerCountry.equals(countryCode) && (euCountryCode || !applyVatToForeignBusiness.getAsBoolean()); return Optional.of(new VatDetail(vatNr, countryCode, true, "", "", euCountryCode ? VatDetail.Type.SKIPPED : VatDetail.Type.EXTRA_EU, vatExempt)); }; } - public void logSuccessfulValidation(VatDetail detail, String reservationId, int eventId) { + public void logSuccessfulValidation(VatDetail detail, String reservationId, Integer eventId) { List> modifications = List.of( Map.of("vatNumber", detail.getVatNr(), "country", detail.getCountry(), "validationType", detail.getType()) ); @@ -140,12 +137,12 @@ private static VatDetail getVatDetail(boolean reverseChargeEnabled, EUVatCheckRe return new VatDetail(vatNr, countryCode, isValid, response.getName(), response.getAddress(), VatDetail.Type.VIES, isValid && reverseChargeEnabled && !organizerCountryCode.equals(countryCode)); } - static String organizerCountry(ConfigurationManager configurationManager, EventAndOrganizationId eventAndOrganizationId) { - return configurationManager.getFor(ConfigurationKeys.COUNTRY_OF_BUSINESS, ConfigurationLevel.event(eventAndOrganizationId)).getValueOrNull(); + static String organizerCountry(ConfigurationManager configurationManager, Configurable configurable) { + return configurationManager.getFor(ConfigurationKeys.COUNTRY_OF_BUSINESS, configurable.getConfigurationLevel()).getValueOrNull(); } - private static boolean reverseChargeEnabled(ConfigurationManager configurationManager, EventAndOrganizationId eventAndOrganizationId) { - var res = configurationManager.getFor(Set.of(ENABLE_EU_VAT_DIRECTIVE, ConfigurationKeys.COUNTRY_OF_BUSINESS), ConfigurationLevel.event(eventAndOrganizationId)); + private static boolean reverseChargeEnabled(ConfigurationManager configurationManager, Configurable configurable) { + var res = configurationManager.getFor(Set.of(ENABLE_EU_VAT_DIRECTIVE, ConfigurationKeys.COUNTRY_OF_BUSINESS), configurable.getConfigurationLevel()); return reverseChargeEnabled(res); } @@ -159,8 +156,8 @@ public static boolean reverseChargeEnabled(Map new MapSqlParameterSource("eventId", eventId) + .addValue("subscriptionId", id) + .addValue("pricePerTicket", 0) + .addValue("organizationId", organizationId)) + .toArray(MapSqlParameterSource[]::new); + var result = jdbcTemplate.batchUpdate(SubscriptionRepository.INSERT_SUBSCRIPTION_LINK, parameters); + Validate.isTrue(Arrays.stream(result).allMatch(r -> r == 1), "Cannot link subscription"); + } + } + public void toggleActiveFlag(int id, String username, boolean activate) { Event event = eventRepository.findById(id); checkOwnership(event, username, event.getOrganizationId()); @@ -367,6 +382,16 @@ public void updateEventPrices(EventAndOrganizationId original, EventModification Validate.isTrue(ids.size() == invalidatedTickets, String.format("error during ticket invalidation: expected %d, got %d", ids.size(), invalidatedTickets)); } } + int organizationId = original.getOrganizationId(); + if(CollectionUtils.isNotEmpty(em.getLinkedSubscriptions())) { + int removed = subscriptionRepository.removeStaleSubscriptions(eventId, organizationId, em.getLinkedSubscriptions()); + log.trace("removed {} subscription links", removed); + createSubscriptionLinks(eventId, organizationId, em); + } else if (em.getLinkedSubscriptions() != null) { + // the user removed all the subscriptions + int removed = subscriptionRepository.removeAllSubscriptionsForEvent(eventId, organizationId); + log.trace("removed all subscription links ({}) for event {}", removed, eventId); + } } private void validatePaymentProxies(List paymentProxies, int organizationId) { @@ -935,7 +960,7 @@ public List findPromoCodesInOrganiz } public String getEventUrl(Event event) { - var baseUrl = configurationManager.getFor(ConfigurationKeys.BASE_URL, ConfigurationLevel.event(event)).getRequiredValue(); + var baseUrl = configurationManager.getFor(ConfigurationKeys.BASE_URL, event.getConfigurationLevel()).getRequiredValue(); return StringUtils.removeEnd(baseUrl, "/") + "/event/" + event.getShortName() + "/"; } @@ -945,8 +970,10 @@ public List findAllConfirmedTicketsForCSV(S return ticketRepository.findAllConfirmedForCSV(event.getId()); } - public List getPublishedEvents() { - return getActiveEventsStream().filter(e -> e.getStatus() == Event.Status.PUBLIC).collect(toList()); + public List getPublishedEvents(EventSearchOptions searchOptions) { + return eventRepository.findVisibleBySearchOptions(searchOptions.getSubscriptionCodeUUIDOrNull(), + searchOptions.getOrganizer(), + searchOptions.getTags()); } public List getActiveEvents() { diff --git a/src/main/java/alfio/manager/EventStatisticsManager.java b/src/main/java/alfio/manager/EventStatisticsManager.java index 8457a2a099..461fe6156c 100644 --- a/src/main/java/alfio/manager/EventStatisticsManager.java +++ b/src/main/java/alfio/manager/EventStatisticsManager.java @@ -16,7 +16,6 @@ */ package alfio.manager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.user.UserManager; import alfio.model.*; @@ -57,6 +56,7 @@ public class EventStatisticsManager { private final SpecialPriceRepository specialPriceRepository; private final ConfigurationManager configurationManager; private final UserManager userManager; + private final SubscriptionRepository subscriptionRepository; private List getAllEvents(String username) { List orgIds = userManager.findUserOrganizations(username).stream().map(Organization::getId).collect(toList()); @@ -81,7 +81,7 @@ public List getAllEventsWithStatisticsFilteredBy(String username } private boolean displayStatisticsForEvent(EventAndOrganizationId event) { - return configurationManager.getFor(DISPLAY_STATS_IN_EVENT_DETAIL, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + return configurationManager.getFor(DISPLAY_STATS_IN_EVENT_DETAIL, event.getConfigurationLevel()).getValueAsBooleanOrDefault(); } @@ -110,7 +110,13 @@ public EventWithAdditionalInfo getEventWithAdditionalInfo(String eventName, Stri .map(t -> new TicketCategoryWithAdditionalInfo(event, t, ticketCategoriesStatistics.get(t.getId()), descriptions.get(t.getId()), specialPrices.get(t.getId()), metadata.get(t.getId()))) .collect(Collectors.toList()); - return new EventWithAdditionalInfo(event, tWithInfo, eventStatistic, description, grossIncome, eventRepository.getMetadataForEvent(event.getId())); + return new EventWithAdditionalInfo(event, + tWithInfo, + eventStatistic, + description, + grossIncome, + eventRepository.getMetadataForEvent(event.getId()), + subscriptionRepository.findLinkedSubscriptionIds(event.getId(), event.getOrganizationId())); } private Event getEventAndCheckOwnership(String eventName, String username) { diff --git a/src/main/java/alfio/manager/ExtensionManager.java b/src/main/java/alfio/manager/ExtensionManager.java index 958a2a2cac..46817664fb 100644 --- a/src/main/java/alfio/manager/ExtensionManager.java +++ b/src/main/java/alfio/manager/ExtensionManager.java @@ -120,9 +120,7 @@ AlfioMetadata handleMetadataUpdate(Event event, Organization organization, Alfio return syncCall(ExtensionEvent.EVENT_METADATA_UPDATE, event, payload, AlfioMetadata.class); } - void handleReservationConfirmation(TicketReservation reservation, BillingDetails billingDetails, int eventId) { - Event event = eventRepository.findById(eventId); - + void handleReservationConfirmation(TicketReservation reservation, BillingDetails billingDetails, PurchaseContext purchaseContext) { Map payload = new HashMap<>(); payload.put("reservation", reservation); payload.put("billingDetails", billingDetails); @@ -130,7 +128,7 @@ void handleReservationConfirmation(TicketReservation reservation, BillingDetails transactionRepository.loadOptionalByReservationId(reservation.getId()) .ifPresent(tr -> payload.put("transaction", tr)); asyncCall(ExtensionEvent.RESERVATION_CONFIRMED, - event, + purchaseContext, payload); } @@ -153,12 +151,12 @@ void handleWaitingQueueSubscription(WaitingQueueSubscription waitingQueueSubscri Map.of("waitingQueueSubscription", waitingQueueSubscription, "additionalInfo", Map.of())); } - void handleReservationsExpiredForEvent(Event event, Collection reservationIdsToRemove) { - handleReservationRemoval(event, reservationIdsToRemove, ExtensionEvent.RESERVATION_EXPIRED); + void handleReservationsExpiredForEvent(PurchaseContext purchaseContext, Collection reservationIdsToRemove) { + handleReservationRemoval(purchaseContext, reservationIdsToRemove, ExtensionEvent.RESERVATION_EXPIRED); } - void handleReservationsCancelledForEvent(Event event, Collection reservationIdsToRemove) { - handleReservationRemoval(event, reservationIdsToRemove, ExtensionEvent.RESERVATION_CANCELLED); + void handleReservationsCancelledForEvent(PurchaseContext purchaseContext, Collection reservationIdsToRemove) { + handleReservationRemoval(purchaseContext, reservationIdsToRemove, ExtensionEvent.RESERVATION_CANCELLED); } void handleTicketCancelledForEvent(Event event, Collection ticketUUIDs) { @@ -180,14 +178,14 @@ void handleStuckReservations(Event event, List stuckReservationsId) { asyncCall(ExtensionEvent.STUCK_RESERVATIONS, event, payload); } - Optional handleReservationEmailCustomText(Event event, TicketReservation reservation, TicketReservationAdditionalInfo additionalInfo) { + Optional handleReservationEmailCustomText(PurchaseContext purchaseContext, TicketReservation reservation, TicketReservationAdditionalInfo additionalInfo) { Map payload = Map.of( "reservation", reservation, - "event", event, + "purchaseContext", purchaseContext, "billingData", additionalInfo ); try { - return Optional.ofNullable(syncCall(ExtensionEvent.CONFIRMATION_MAIL_CUSTOM_TEXT, event, payload, CustomEmailText.class)); + return Optional.ofNullable(syncCall(ExtensionEvent.CONFIRMATION_MAIL_CUSTOM_TEXT, purchaseContext, payload, CustomEmailText.class)); } catch(Exception ex) { log.warn("Cannot get confirmation mail additional text", ex); return Optional.empty(); @@ -209,12 +207,12 @@ public Optional handleTicketEmailCustomText(Event event, Ticket } } - private void handleReservationRemoval(Event event, Collection reservationIds, ExtensionEvent extensionEvent) { + private void handleReservationRemoval(PurchaseContext purchaseContext, Collection reservationIds, ExtensionEvent extensionEvent) { Map payload = new HashMap<>(); payload.put("reservationIds", reservationIds); payload.put("reservations", ticketReservationRepository.findByIds(reservationIds)); - syncCall(extensionEvent, event, payload, Boolean.class); + syncCall(extensionEvent, purchaseContext, payload, Boolean.class); } public Optional handleInvoiceGeneration(PaymentSpecification spec, TotalPrice reservationCost, BillingDetails billingDetails) { @@ -232,7 +230,7 @@ public Optional handleInvoiceGeneration(PaymentSpecification payload.put("vatNr", billingDetails.getTaxId()); payload.put("vatStatus", spec.getVatStatus()); - return Optional.ofNullable(syncCall(ExtensionEvent.INVOICE_GENERATION, spec.getEvent(), payload, InvoiceGeneration.class)); + return Optional.ofNullable(syncCall(ExtensionEvent.INVOICE_GENERATION, spec.getPurchaseContext(), payload, InvoiceGeneration.class)); } public Optional handleOnlineCheckInLink(String originalUrl, Ticket ticket, EventWithCheckInInfo event) { @@ -244,17 +242,17 @@ public Optional handleOnlineCheckInLink(String originalUrl, Ticket ticke payload.put("originalURL", originalUrl); return Optional.ofNullable(extensionService.executeScriptsForEvent(ONLINE_CHECK_IN_REDIRECT.name(), - toPath(event.getOrganizationId(), event.getId()), + toPath(event), payload, String.class)); } - boolean handleTaxIdValidation(int eventId, String taxIdNumber, String countryCode) { - Event event = eventRepository.findById(eventId); + // FIXME: should not depend only by event id! + boolean handleTaxIdValidation(PurchaseContext purchaseContext, String taxIdNumber, String countryCode) { Map payload = new HashMap<>(); payload.put("taxIdNumber", taxIdNumber); payload.put("countryCode", countryCode); - return Optional.ofNullable(syncCall(ExtensionEvent.TAX_ID_NUMBER_VALIDATION, event, payload, Boolean.class)).orElse(false); + return Optional.ofNullable(syncCall(ExtensionEvent.TAX_ID_NUMBER_VALIDATION, purchaseContext, payload, Boolean.class)).orElse(false); } void handleTicketCheckedIn(Ticket ticket) { @@ -272,7 +270,7 @@ void handleTicketRevertCheckedIn(Ticket ticket) { } @Transactional(readOnly = true) - public void handleReservationValidation(Event event, TicketReservation reservation, Object clientForm, BindingResult bindingResult) { + public void handleReservationValidation(PurchaseContext purchaseContext, TicketReservation reservation, Object clientForm, BindingResult bindingResult) { Map payload = Map.of( "reservationId", reservation.getId(), "reservation", reservation, @@ -281,7 +279,7 @@ public void handleReservationValidation(Event event, TicketReservation reservati "bindingResult", bindingResult ); - syncCall(ExtensionEvent.RESERVATION_VALIDATION, event, payload, Void.class); + syncCall(ExtensionEvent.RESERVATION_VALIDATION, purchaseContext, payload, Void.class); } void handleReservationsCreditNoteIssuedForEvent(Event event, List reservationIds) { @@ -292,19 +290,19 @@ void handleReservationsCreditNoteIssuedForEvent(Event event, List reserv syncCall(ExtensionEvent.RESERVATION_CREDIT_NOTE_ISSUED, event, payload, Boolean.class); } - void handleRefund(Event event, TicketReservation reservation, TransactionAndPaymentInfo info) { + void handleRefund(PurchaseContext purchaseContext, TicketReservation reservation, TransactionAndPaymentInfo info) { Map payload = new HashMap<>(); payload.put("reservation", reservation); payload.put("transaction", info.getTransaction()); payload.put("paymentInfo", info.getPaymentInformation()); - asyncCall(ExtensionEvent.REFUND_ISSUED, event, payload); + asyncCall(ExtensionEvent.REFUND_ISSUED, purchaseContext, payload); } - public boolean handlePdfTransformation(String html, Event event, OutputStream outputStream) { + public boolean handlePdfTransformation(String html, PurchaseContext purchaseContext, OutputStream outputStream) { Map payload = new HashMap<>(); payload.put("html", html); try { - PdfGenerationResult response = syncCall(ExtensionEvent.PDF_GENERATION, event, payload, PdfGenerationResult.class); + PdfGenerationResult response = syncCall(ExtensionEvent.PDF_GENERATION, purchaseContext, payload, PdfGenerationResult.class); if(response == null || response.isEmpty()) { return false; } @@ -321,10 +319,15 @@ public boolean handlePdfTransformation(String html, Event event, OutputStream ou } public Optional generateOAuth2StateParam(int organizationId) { - return Optional.ofNullable(extensionService.executeScriptsForEvent(ExtensionEvent.OAUTH2_STATE_GENERATION.name(), - "-" + organizationId, - Map.of("baseUrl", configurationManager.getFor(ConfigurationKeys.BASE_URL, ConfigurationLevel.organization(organizationId)).getRequiredValue(), "organizationId", organizationId), - String.class)); + try { + return Optional.ofNullable(extensionService.executeScriptsForEvent(ExtensionEvent.OAUTH2_STATE_GENERATION.name(), + "-" + organizationId, + Map.of("baseUrl", configurationManager.getFor(ConfigurationKeys.BASE_URL, ConfigurationLevel.organization(organizationId)).getRequiredValue(), "organizationId", organizationId), + String.class)); + } catch (Exception ex) { + log.error("got exception while generating OAuth2 State Param", ex); + throw new IllegalStateException(ex); + } } public Optional handleDynamicDiscount(Event event, Map quantityByCategory, String reservationId) { @@ -352,17 +355,17 @@ public Optional handleDynamicDiscount(Event event, Map payload) { + private void asyncCall(ExtensionEvent extensionEvent, PurchaseContext event, Map payload) { extensionService.executeScriptAsync(extensionEvent.name(), - toPath(event.getOrganizationId(), event.getId()), + toPath(event), fillWithBasicInfo(payload, event)); } - private T syncCall(ExtensionEvent extensionEvent, Event event, Map payload, Class clazz) { + private T syncCall(ExtensionEvent extensionEvent, PurchaseContext purchaseContext, Map payload, Class clazz) { try { return extensionService.executeScriptsForEvent(extensionEvent.name(), - toPath(event.getOrganizationId(), event.getId()), - fillWithBasicInfo(payload, event), + toPath(purchaseContext), + fillWithBasicInfo(payload, purchaseContext), clazz); } catch(AlfioScriptingException ex) { log.warn("Unexpected exception while executing script:", ex); @@ -370,17 +373,25 @@ private T syncCall(ExtensionEvent extensionEvent, Event event, Map fillWithBasicInfo(Map payload, Event event) { + private Map fillWithBasicInfo(Map payload, PurchaseContext purchaseContext) { Map payloadCopy = new HashMap<>(payload); - payloadCopy.put("event", event); - payloadCopy.put("eventId", event.getId()); - payloadCopy.put("organizationId", event.getOrganizationId()); + //FIXME ugly + purchaseContext.event().ifPresent(event -> { + payloadCopy.put("event", event); + payloadCopy.put("eventId", event.getId()); + }); + payloadCopy.put("purchaseContext", purchaseContext); + payloadCopy.put("organizationId", purchaseContext.getOrganizationId()); return payloadCopy; } + public static String toPath(EventAndOrganizationId event) { + return "-" + event.getOrganizationId() + "-" + event.getId(); + } - public static String toPath(int organizationId, int eventId) { - return "-" + organizationId + "-" + eventId; + public static String toPath(PurchaseContext purchaseContext) { + int organizationId = purchaseContext.getOrganizationId(); + return purchaseContext.event().map(e -> toPath((EventAndOrganizationId) e)).orElseGet(() -> "-" + organizationId); } } diff --git a/src/main/java/alfio/manager/NotificationManager.java b/src/main/java/alfio/manager/NotificationManager.java index 87c4a0925e..3b373d34fc 100644 --- a/src/main/java/alfio/manager/NotificationManager.java +++ b/src/main/java/alfio/manager/NotificationManager.java @@ -25,6 +25,8 @@ import alfio.manager.system.ConfigurationManager; import alfio.manager.system.Mailer; import alfio.model.*; +import alfio.model.PurchaseContext.PurchaseContextType; +import alfio.model.subscription.SubscriptionDescriptor; import alfio.model.system.ConfigurationKeys; import alfio.model.user.Organization; import alfio.repository.*; @@ -34,7 +36,6 @@ import com.google.gson.*; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.time.DateUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; import org.springframework.beans.factory.annotation.Autowired; @@ -55,7 +56,6 @@ import java.security.NoSuchAlgorithmException; import java.time.ZonedDateTime; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; @@ -63,6 +63,7 @@ import static alfio.model.EmailMessage.Status.*; import static alfio.model.system.ConfigurationKeys.BASE_URL; +import static java.util.Objects.requireNonNullElse; @Component @Log4j2 @@ -78,6 +79,7 @@ public class NotificationManager { private final Gson gson; private final TicketCategoryRepository ticketCategoryRepository; private final ClockProvider clockProvider; + private final PurchaseContextManager purchaseContextManager; private final EnumMap, byte[]>> attachmentTransformer; @@ -99,7 +101,8 @@ public NotificationManager(Mailer mailer, TicketFieldRepository ticketFieldRepository, AdditionalServiceItemRepository additionalServiceItemRepository, ExtensionManager extensionManager, - ClockProvider clockProvider) { + ClockProvider clockProvider, + PurchaseContextManager purchaseContextManager) { this.messageSourceManager = messageSourceManager; this.mailer = mailer; this.emailMessageRepository = emailMessageRepository; @@ -113,13 +116,14 @@ public NotificationManager(Mailer mailer, builder.registerTypeAdapter(Mailer.Attachment.class, new AttachmentConverter()); this.gson = builder.create(); this.clockProvider = clockProvider; + this.purchaseContextManager = purchaseContextManager; attachmentTransformer = new EnumMap<>(Mailer.AttachmentIdentifier.class); attachmentTransformer.put(Mailer.AttachmentIdentifier.CALENDAR_ICS, generateICS(eventRepository, eventDescriptionRepository, ticketCategoryRepository, organizationRepository, messageSourceManager)); - attachmentTransformer.put(Mailer.AttachmentIdentifier.RECEIPT_PDF, receiptOrInvoiceFactory(eventRepository, + attachmentTransformer.put(Mailer.AttachmentIdentifier.RECEIPT_PDF, receiptOrInvoiceFactory(purchaseContextManager, eventRepository, payload -> TemplateProcessor.buildReceiptPdf(payload.getLeft(), fileUploadManager, payload.getMiddle(), templateManager, payload.getRight(), extensionManager))); - attachmentTransformer.put(Mailer.AttachmentIdentifier.INVOICE_PDF, receiptOrInvoiceFactory(eventRepository, + attachmentTransformer.put(Mailer.AttachmentIdentifier.INVOICE_PDF, receiptOrInvoiceFactory(purchaseContextManager, eventRepository, payload -> TemplateProcessor.buildInvoicePdf(payload.getLeft(), fileUploadManager, payload.getMiddle(), templateManager, payload.getRight(), extensionManager))); - attachmentTransformer.put(Mailer.AttachmentIdentifier.CREDIT_NOTE_PDF, receiptOrInvoiceFactory(eventRepository, + attachmentTransformer.put(Mailer.AttachmentIdentifier.CREDIT_NOTE_PDF, receiptOrInvoiceFactory(purchaseContextManager, eventRepository, payload -> TemplateProcessor.buildCreditNotePdf(payload.getLeft(), fileUploadManager, payload.getMiddle(), templateManager, payload.getRight(), extensionManager))); attachmentTransformer.put(Mailer.AttachmentIdentifier.PASSBOOK, passKitManager::getPass); Function> retrieveFieldValues = EventUtil.retrieveFieldValues(ticketRepository, ticketFieldRepository, additionalServiceItemRepository); @@ -174,7 +178,7 @@ private static Function, byte[]> generateICS(EventRepository TicketCategory category = Optional.ofNullable(categoryId).map(ticketCategoryRepository::getById).orElse(null); String description = eventDescriptionRepository.findDescriptionByEventIdTypeAndLocale(event.getId(), EventDescription.EventDescriptionType.DESCRIPTION, locale.getLanguage()).orElse(""); if(model.containsKey("onlineCheckInUrl")) { // special case: online event - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); description = description + buildOnlineCheckInInformation(messageSource).apply(model, locale); } return EventUtil.getIcalForEvent(event, category, description, organization).orElse(null); @@ -194,17 +198,25 @@ public String buildOnlineCheckInText(Map model, Locale locale, M return buildOnlineCheckInInformation(messageSource).apply(model, locale); } - private static Function, byte[]> receiptOrInvoiceFactory(EventRepository eventRepository, Function>, Optional> pdfGenerator) { + private static Function, byte[]> receiptOrInvoiceFactory(PurchaseContextManager purchaseContextManager, EventRepository eventRepository, Function>, Optional> pdfGenerator) { return model -> { String reservationId = model.get("reservationId"); - Event event = eventRepository.findById(Integer.valueOf(model.get("eventId"), 10)); + PurchaseContext purchaseContext; + Map reservationEmailModel = Json.fromJson(model.get("reservationEmailModel"), new TypeReference<>() {}); + if (reservationEmailModel.get("purchaseContext") != null) { + @SuppressWarnings("unchecked") + var purchaseContextModel = (Map) reservationEmailModel.get("purchaseContext"); + // FIXME hack + var purchaseContextType = model.get("eventId") != null ? PurchaseContextType.event : PurchaseContextType.subscription; + purchaseContext = purchaseContextManager.findBy(purchaseContextType, purchaseContextModel.get("publicIdentifier")).orElseThrow(); + } else { + purchaseContext = eventRepository.findById(Integer.valueOf(model.get("eventId"), 10)); + } Locale language = Json.fromJson(model.get("language"), Locale.class); - Map reservationEmailModel = Json.fromJson(model.get("reservationEmailModel"), new TypeReference<>() { - }); + Optional receipt = pdfGenerator.apply(Triple.of(purchaseContext, language, reservationEmailModel)); //FIXME hack: reservationEmailModel should be a minimal and typed container - reservationEmailModel.put("event", event); - Optional receipt = pdfGenerator.apply(Triple.of(event, language, reservationEmailModel)); + reservationEmailModel.put("event", purchaseContext); if(receipt.isEmpty()) { log.warn("was not able to generate the receipt for reservation id " + reservationId + " for locale " + language); @@ -237,7 +249,7 @@ public void sendTicketByEmail(Ticket ticket, String displayName = event.getDisplayName(); String encodedAttachments = encodeAttachments(attachments.toArray(new Mailer.Attachment[0])); - String subject = messageSourceManager.getMessageSourceForEvent(event).getMessage("ticket-email-subject", new Object[]{displayName}, locale); + String subject = messageSourceManager.getMessageSourceFor(event).getMessage("ticket-email-subject", new Object[]{displayName}, locale); var renderedTemplate = textBuilder.generate(ticket); String checksum = calculateChecksum(ticket.getEmail(), encodedAttachments, subject, renderedTemplate); String recipient = ticket.getEmail(); @@ -245,19 +257,19 @@ public void sendTicketByEmail(Ticket ticket, tx.execute(status -> { emailMessageRepository.findIdByEventIdAndChecksum(event.getId(), checksum).ifPresentOrElse( // see issue #967 - id -> emailMessageRepository.updateStatusToWaitingWithHtml(event.getId(), id, renderedTemplate.getHtmlPart()), - () -> emailMessageRepository.insert(event.getId(), reservation.getId(), recipient, null, subject, renderedTemplate.getTextPart(), renderedTemplate.getHtmlPart(), encodedAttachments, checksum, ZonedDateTime.now(clockProvider.getClock())) + id -> emailMessageRepository.updateStatusToWaitingWithHtml(id, renderedTemplate.getHtmlPart()), + () -> emailMessageRepository.insert(event.getId(), null, reservation.getId(), recipient, null, subject, renderedTemplate.getTextPart(), renderedTemplate.getHtmlPart(), encodedAttachments, checksum, ZonedDateTime.now(clockProvider.getClock()), event.getOrganizationId()) ); return null; }); } - public void sendSimpleEmail(EventAndOrganizationId event, String reservationId, String recipient, List cc, String subject, TemplateGenerator textBuilder) { - sendSimpleEmail(event, reservationId, recipient, cc, subject, textBuilder, Collections.emptyList()); + public void sendSimpleEmail(PurchaseContext purchaseContext, String reservationId, String recipient, List cc, String subject, TemplateGenerator textBuilder) { + sendSimpleEmail(purchaseContext, reservationId, recipient, cc, subject, textBuilder, Collections.emptyList()); } - public List getCCForEventOrganizer(EventAndOrganizationId event) { - var systemNotificationCC = configurationManager.getFor(ConfigurationKeys.MAIL_SYSTEM_NOTIFICATION_CC, ConfigurationLevel.event(event)).getValueOrDefault(""); + public List getCCForEventOrganizer(PurchaseContext purchaseContext) { + var systemNotificationCC = configurationManager.getFor(ConfigurationKeys.MAIL_SYSTEM_NOTIFICATION_CC, purchaseContext.getConfigurationLevel()).getValueOrDefault(""); return Stream.of(StringUtils.split(systemNotificationCC, ',')) .filter(Objects::nonNull) .map(String::trim) @@ -265,15 +277,15 @@ public List getCCForEventOrganizer(EventAndOrganizationId event) { .collect(Collectors.toList()); } - public void sendSimpleEmail(EventAndOrganizationId event, String reservationId, String recipient, String subject, TemplateGenerator textBuilder) { + public void sendSimpleEmail(PurchaseContext event, String reservationId, String recipient, String subject, TemplateGenerator textBuilder) { sendSimpleEmail(event, reservationId, recipient, Collections.emptyList(), subject, textBuilder); } - public void sendSimpleEmail(EventAndOrganizationId event, String reservationId, String recipient, String subject, TemplateGenerator textBuilder, List attachments) { - sendSimpleEmail(event, reservationId, recipient, Collections.emptyList(), subject, textBuilder, attachments); + public void sendSimpleEmail(PurchaseContext purchaseContext, String reservationId, String recipient, String subject, TemplateGenerator textBuilder, List attachments) { + sendSimpleEmail(purchaseContext, reservationId, recipient, Collections.emptyList(), subject, textBuilder, attachments); } - public void sendSimpleEmail(EventAndOrganizationId event, String reservationId, String recipient, List cc, String subject, TemplateGenerator textBuilder, List attachments) { + public void sendSimpleEmail(PurchaseContext purchaseContext, String reservationId, String recipient, List cc, String subject, TemplateGenerator textBuilder, List attachments) { String encodedAttachments = attachments.isEmpty() ? null : encodeAttachments(attachments.toArray(new Mailer.Attachment[0])); String encodedCC = Json.toJson(cc); @@ -281,78 +293,96 @@ public void sendSimpleEmail(EventAndOrganizationId event, String reservationId, var renderedTemplate = textBuilder.generate(); String checksum = calculateChecksum(recipient, encodedAttachments, subject, renderedTemplate); //in order to minimize the database size, it is worth checking if there is already another message in the table - Optional existing = emailMessageRepository.findIdByEventIdAndChecksum(event.getId(), checksum); + Optional existing = emailMessageRepository.findIdByPurchaseContextAndChecksum(purchaseContext, checksum); existing.ifPresentOrElse(id -> //see issue #967 - emailMessageRepository.updateStatusToWaitingWithHtml(event.getId(), id, renderedTemplate.getHtmlPart()) + emailMessageRepository.updateStatusToWaitingWithHtml(id, renderedTemplate.getHtmlPart()) , - () -> emailMessageRepository.insert(event.getId(), reservationId, recipient, encodedCC, subject, renderedTemplate.getTextPart(), renderedTemplate.getHtmlPart(), encodedAttachments, checksum, ZonedDateTime.now(clockProvider.getClock()))); + () -> { + var pair = getEventIdSubscriptionId(purchaseContext); + emailMessageRepository.insert(pair.getLeft(), pair.getRight(), reservationId, recipient, encodedCC, subject, renderedTemplate.getTextPart(), renderedTemplate.getHtmlPart(), encodedAttachments, checksum, ZonedDateTime.now(clockProvider.getClock()), purchaseContext.getOrganizationId()); + }); } - public Pair> loadAllMessagesForEvent(int eventId, Integer page, String search) { + private static Pair getEventIdSubscriptionId(PurchaseContext purchaseContext) { + if(purchaseContext.getType() == PurchaseContextType.event) { + return Pair.of(((Event)purchaseContext).getId(), null); + } else { + return Pair.of(null, ((SubscriptionDescriptor) purchaseContext).getId()); + } + } + + public Pair> loadAllMessagesForPurchaseContext(PurchaseContext purchaseContext, Integer page, String search) { final int pageSize = 50; int offset = page == null ? 0 : page * pageSize; String toSearch = StringUtils.trimToNull(search); toSearch = toSearch == null ? null : ("%" + toSearch + "%"); - return Pair.of(emailMessageRepository.countFindByEventId(eventId, toSearch), emailMessageRepository.findByEventId(eventId, offset, pageSize, toSearch)); + if(purchaseContext.getType() == PurchaseContextType.event) { + int eventId = ((Event) purchaseContext).getId(); + return Pair.of(emailMessageRepository.countFindByEventId(eventId, toSearch), emailMessageRepository.findByEventId(eventId, offset, pageSize, toSearch)); + } else { + var subscriptionDescriptorId = ((SubscriptionDescriptor)purchaseContext).getId(); + return Pair.of(emailMessageRepository.countFindBySubscriptionDescriptorId(subscriptionDescriptorId, toSearch), emailMessageRepository.findBySubscriptionDescriptorId(subscriptionDescriptorId, offset, pageSize, toSearch)); + } } - public List loadAllMessagesForReservationId(int eventId, String reservationId) { - return emailMessageRepository.findByEventIdAndReservationId(eventId, reservationId); + public List loadAllMessagesForReservationId(PurchaseContext purchaseContext, String reservationId) { + return emailMessageRepository.findByPurchaseContextAndReservationId(purchaseContext, reservationId); } - public Optional loadSingleMessageForEvent(int eventId, int messageId) { - return emailMessageRepository.findByEventIdAndMessageId(eventId, messageId); + public Optional loadSingleMessageForPurchaseContext(PurchaseContext purchaseContext, int messageId) { + if(purchaseContext.getType() == PurchaseContextType.event) { + return emailMessageRepository.findByEventIdAndMessageId(((Event)purchaseContext).getId(), messageId); + } else { + return emailMessageRepository.findBySubscriptionDescriptorIdAndMessageId(((SubscriptionDescriptor)purchaseContext).getId(), messageId); + } } @Transactional public int sendWaitingMessages() { - Date now = new Date(); - - emailMessageRepository.setToRetryOldInProcess(DateUtils.addHours(now, -1)); - - AtomicInteger counter = new AtomicInteger(); - - eventRepository.findAllActiveIds(ZonedDateTime.now(clockProvider.getClock())) - .stream() - .flatMap(id -> emailMessageRepository.loadIdsWaitingForProcessing(id, now).stream()) - .distinct() - .forEach(messageId -> counter.addAndGet(processMessage(messageId))); - return counter.get(); + emailMessageRepository.setToRetryOldInProcess(ZonedDateTime.now(clockProvider.getClock()).minusHours(1)); + return emailMessageRepository.loadAllWaitingForProcessing().stream() + .collect(Collectors.groupingBy(NotificationManager::purchaseContextCacheKey)) + .entrySet().stream() + .flatMapToInt(entry -> { + var splitKey = entry.getKey().split("//"); + PurchaseContext purchaseContext = purchaseContextManager.findById(PurchaseContextType.from(splitKey[0]), splitKey[1]).orElseThrow(); + // TODO we can try to send emails in batches, if the provider supports it. + return entry.getValue().stream().mapToInt(message -> processMessage(message, purchaseContext)); + }).sum(); } - private int processMessage(int messageId) { - EmailMessage message = emailMessageRepository.findById(messageId); - EventAndOrganizationId event = eventRepository.findEventAndOrganizationIdById(message.getEventId()); - if(message.getAttempts() >= configurationManager.getFor(ConfigurationKeys.MAIL_ATTEMPTS_COUNT, ConfigurationLevel.event(event)).getValueAsIntOrDefault(10)) { + private int processMessage(EmailMessage message, PurchaseContext purchaseContext) { + int messageId = message.getId(); + ConfigurationLevel configurationLevel = ConfigurationLevel.purchaseContext(purchaseContext); + if(message.getAttempts() >= configurationManager.getFor(ConfigurationKeys.MAIL_ATTEMPTS_COUNT, configurationLevel).getValueAsIntOrDefault(10)) { tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(messageId, ERROR.name(), message.getAttempts(), Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name()))); - log.warn("Message with id " + messageId + " will be discarded"); + log.warn("Message with id {} will be discarded", messageId); return 0; } - try { - int result = Optional.ofNullable(tx.execute(status -> emailMessageRepository.updateStatus(message.getEventId(), message.getChecksum(), IN_PROCESS.name(), Arrays.asList(WAITING.name(), RETRY.name())))).orElse(0); + int result = Optional.ofNullable(tx.execute(status -> emailMessageRepository.updateStatus(messageId, message.getChecksum(), IN_PROCESS.name(), Arrays.asList(WAITING.name(), RETRY.name())))).orElse(0); if(result > 0) { return Optional.ofNullable(tx.execute(status -> { - sendMessage(event, message); + sendMessage(purchaseContext, message); return 1; })).orElse(0); } else { - log.debug("no messages have been updated on DB for the following criteria: eventId: {}, checksum: {}", message.getEventId(), message.getChecksum()); + log.debug("no messages have been updated on DB for the following criteria: id: {}, checksum: {}", messageId, message.getChecksum()); } } catch(Exception e) { - tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(message.getId(), RETRY.name(), DateUtils.addMinutes(new Date(), message.getAttempts() + 1), message.getAttempts() + 1, Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name()))); + tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(message.getId(), RETRY.name(), ZonedDateTime.now(clockProvider.getClock()).plusMinutes(message.getAttempts() + 1), message.getAttempts() + 1, Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name()))); log.warn("could not send message: ",e); } return 0; } - private void sendMessage(EventAndOrganizationId event, EmailMessage message) { - String displayName = eventRepository.getDisplayNameById(message.getEventId()); - mailer.send(event, displayName, message.getRecipient(), message.getCc(), message.getSubject(), message.getMessage(), Optional.ofNullable(message.getHtmlMessage()), decodeAttachments(message.getAttachments())); - emailMessageRepository.updateStatusToSent(message.getEventId(), message.getChecksum(), ZonedDateTime.now(clockProvider.getClock()), Collections.singletonList(IN_PROCESS.name())); + private void sendMessage(PurchaseContext purchaseContext, EmailMessage message) { + // FIXME save the locale of the message, so that we can retrieve its title + mailer.send(purchaseContext, purchaseContext.getDisplayName(), message.getRecipient(), message.getCc(), message.getSubject(), message.getMessage(), Optional.ofNullable(message.getHtmlMessage()), decodeAttachments(message.getAttachments())); + emailMessageRepository.updateStatusToSent(message.getId(), message.getChecksum(), ZonedDateTime.now(clockProvider.getClock()), Collections.singletonList(IN_PROCESS.name())); } private String encodeAttachments(Mailer.Attachment... files) { @@ -433,8 +463,13 @@ public Mailer.Attachment deserialize(JsonElement json, Type typeOfT, JsonDeseria byte[] source = jsonObject.has("source") ? Base64.getDecoder().decode(jsonObject.getAsJsonPrimitive("source").getAsString()) : null; String contentType = jsonObject.getAsJsonPrimitive("contentType").getAsString(); Mailer.AttachmentIdentifier identifier = jsonObject.has("identifier") ? Mailer.AttachmentIdentifier.valueOf(jsonObject.getAsJsonPrimitive("identifier").getAsString()) : null; - Map model = jsonObject.has("model") ? Json.fromJson(jsonObject.getAsJsonPrimitive("model").getAsString(), new TypeReference>() {}) : null; + Map model = jsonObject.has("model") ? Json.fromJson(jsonObject.getAsJsonPrimitive("model").getAsString(), new TypeReference<>() {}) : null; return new Mailer.Attachment(filename, source, contentType, model, identifier); } } + + private static String purchaseContextCacheKey(EmailMessage message) { + return message.getPurchaseContextType() + "//" + + requireNonNullElse(message.getEventId(), message.getSubscriptionDescriptorId()); + } } diff --git a/src/main/java/alfio/manager/PassKitManager.java b/src/main/java/alfio/manager/PassKitManager.java index bba94bda18..676ccf3145 100644 --- a/src/main/java/alfio/manager/PassKitManager.java +++ b/src/main/java/alfio/manager/PassKitManager.java @@ -16,7 +16,6 @@ */ package alfio.manager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.system.ConfigurationKeys; @@ -112,7 +111,7 @@ private Map getConfigurationKeys(EventAndOrganization var conf = configurationManager.getFor(Set.of(ENABLE_PASS, PASSBOOK_TYPE_IDENTIFIER, PASSBOOK_KEYSTORE, PASSBOOK_KEYSTORE_PASSWORD, - PASSBOOK_TEAM_IDENTIFIER, PASSBOOK_PRIVATE_KEY_ALIAS), ConfigurationLevel.event(event)); + PASSBOOK_TEAM_IDENTIFIER, PASSBOOK_PRIVATE_KEY_ALIAS), event.getConfigurationLevel()); if(!conf.get(ENABLE_PASS).getValueAsBooleanOrDefault()) { return Map.of(); @@ -248,7 +247,7 @@ public Optional> validateToken(String event } var event = eventOptional.get(); - var typeIdentifierOptional = configurationManager.getFor(PASSBOOK_TYPE_IDENTIFIER, ConfigurationLevel.event(event)); + var typeIdentifierOptional = configurationManager.getFor(PASSBOOK_TYPE_IDENTIFIER, event.getConfigurationLevel()); if(!typeIdentifierOptional.isPresent() || !typeIdentifier.equals(typeIdentifierOptional.getValueOrNull())) { log.trace("typeIdentifier does not match. Expected {}, got {}", typeIdentifierOptional.getValueOrDefault("not-found"), typeIdentifier); return Optional.empty(); diff --git a/src/main/java/alfio/manager/PaymentManager.java b/src/main/java/alfio/manager/PaymentManager.java index 4ee578f344..7815460d75 100644 --- a/src/main/java/alfio/manager/PaymentManager.java +++ b/src/main/java/alfio/manager/PaymentManager.java @@ -114,7 +114,7 @@ private Stream compatibleStream(PaymentMethod paymentMethod, Pa private List getPaymentMethods(PaymentContext context, TransactionRequest transactionRequest) { String blacklist = configurationManager.getFor(ConfigurationKeys.PAYMENT_METHODS_BLACKLIST, context.getConfigurationLevel()).getValueOrDefault(""); - var proxies = Optional.ofNullable(context.getEvent()).map(Event::getAllowedPaymentProxies).orElseGet(PaymentProxy::availableProxies); + var proxies = Optional.ofNullable(context.getPurchaseContext()).map(PurchaseContext::getAllowedPaymentProxies).orElseGet(PaymentProxy::availableProxies); return proxies.stream() .filter(p -> !blacklist.contains(p.getKey())) .map(proxy -> Pair.of(proxy, paymentMethodsByProxy(context, transactionRequest, proxy))) @@ -129,19 +129,19 @@ private Set paymentMethodsByProxy(PaymentContext context, Transac .collect(Collectors.toSet()); } - public List getPaymentMethods(Event event, TransactionRequest transactionRequest) { - return getPaymentMethods(new PaymentContext(event), transactionRequest); + public List getPaymentMethods(PurchaseContext purchaseContext, TransactionRequest transactionRequest) { + return getPaymentMethods(new PaymentContext(purchaseContext), transactionRequest); } public List getPaymentMethods(int organizationId) { return getPaymentMethods(new PaymentContext(null, ConfigurationLevel.organization(organizationId)), TransactionRequest.empty()); } - public boolean refund(TicketReservation reservation, Event event, Integer amount, String username) { + public boolean refund(TicketReservation reservation, PurchaseContext purchaseContext, Integer amount, String username) { Transaction transaction = transactionRepository.loadByReservationId(reservation.getId()); boolean res = lookupProviderByTransactionAndCapabilities(transaction, List.of(RefundRequest.class)) - .map(paymentProvider -> ((RefundRequest)paymentProvider).refund(transaction, event, amount)) + .map(paymentProvider -> ((RefundRequest)paymentProvider).refund(transaction, purchaseContext, amount)) .orElse(false); Map changes = Map.of( @@ -150,13 +150,13 @@ public boolean refund(TicketReservation reservation, Event event, Integer amount ); if(res) { auditingRepository.insert(reservation.getId(), userRepository.findIdByUserName(username).orElse(null), - event.getId(), + purchaseContext, Audit.EventType.REFUND, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), Collections.singletonList(changes)); - extensionManager.handleRefund(event, reservation, getInfo(reservation, event)); + extensionManager.handleRefund(purchaseContext, reservation, getInfo(reservation, purchaseContext)); } else { auditingRepository.insert(reservation.getId(), userRepository.findIdByUserName(username).orElse(null), - event.getId(), + purchaseContext, Audit.EventType.REFUND_ATTEMPT_FAILED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), Collections.singletonList(changes)); } @@ -164,9 +164,9 @@ Audit.EventType.REFUND_ATTEMPT_FAILED, new Date(), Audit.EntityType.RESERVATION, return res; } - TransactionAndPaymentInfo getInfo(TicketReservation reservation, Event event) { + TransactionAndPaymentInfo getInfo(TicketReservation reservation, PurchaseContext purchaseContext) { Optional maybeTransaction = transactionRepository.loadOptionalByReservationId(reservation.getId()) - .map(transaction -> internalGetInfo(reservation, event, transaction)); + .map(transaction -> internalGetInfo(reservation, purchaseContext, transaction)); maybeTransaction.ifPresent(info -> { try { Transaction transaction = info.getTransaction(); @@ -187,10 +187,10 @@ private boolean feesUpdated(Transaction transaction, PaymentInformation paymentI || transaction.getGatewayFee() != safeParseLong(paymentInformation.getFee()); } - private TransactionAndPaymentInfo internalGetInfo(TicketReservation reservation, Event event, Transaction transaction) { + private TransactionAndPaymentInfo internalGetInfo(TicketReservation reservation, PurchaseContext purchaseContext, Transaction transaction) { return lookupProviderByTransactionAndCapabilities(transaction, List.of(PaymentInfo.class)) .map(provider -> { - Optional info = ((PaymentInfo) provider).getInfo(transaction, event); + Optional info = ((PaymentInfo) provider).getInfo(transaction, purchaseContext); return new TransactionAndPaymentInfo(reservation.getPaymentMethod(), transaction, info.orElse(null)); }) .orElseGet(() -> { @@ -210,8 +210,8 @@ private static long safeParseLong(String src) { return Optional.ofNullable(src).map(Long::parseLong).orElse(0L); } - public Map loadModelOptionsFor(List activePaymentMethods, Event event) { - PaymentContext context = new PaymentContext(event); + public Map loadModelOptionsFor(List activePaymentMethods, PurchaseContext purchaseContext) { + PaymentContext context = new PaymentContext(purchaseContext); return activePaymentMethods.stream() .flatMap(pp -> getProviderOptions(context, pp)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); diff --git a/src/main/java/alfio/manager/PurchaseContextManager.java b/src/main/java/alfio/manager/PurchaseContextManager.java new file mode 100644 index 0000000000..97fdf6b3ad --- /dev/null +++ b/src/main/java/alfio/manager/PurchaseContextManager.java @@ -0,0 +1,66 @@ +/** + * 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.model.PurchaseContext; +import alfio.repository.EventRepository; +import alfio.repository.SubscriptionRepository; +import alfio.repository.TicketReservationRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +@Transactional(readOnly = true) +@Component +public class PurchaseContextManager { + + private final EventRepository eventRepository; + private final SubscriptionRepository subscriptionRepository; + private final TicketReservationRepository ticketReservationRepository; + + public PurchaseContextManager(EventRepository eventRepository, + SubscriptionRepository subscriptionRepository, + TicketReservationRepository ticketReservationRepository) { + this.eventRepository = eventRepository; + this.subscriptionRepository = subscriptionRepository; + this.ticketReservationRepository = ticketReservationRepository; + } + + public Optional findBy(PurchaseContext.PurchaseContextType purchaseContextType, String publicIdentifier) { + switch (purchaseContextType) { + case event: return eventRepository.findOptionalByShortName(publicIdentifier); + case subscription: return subscriptionRepository.findOne(UUID.fromString(publicIdentifier)); + default: throw new IllegalStateException("not a covered type " + purchaseContextType); + } + } + + Optional findById(PurchaseContext.PurchaseContextType purchaseContextType, String idAsString) { + switch (purchaseContextType) { + case event: return eventRepository.findOptionalById(Integer.parseInt(idAsString)); + case subscription: return subscriptionRepository.findOne(UUID.fromString(idAsString)); + default: throw new IllegalStateException("not a covered type " + purchaseContextType); + } + } + + public Optional findByReservationId(String reservationId) { + return ticketReservationRepository.findEventIdFor(reservationId).map(eventRepository::findById) + .map(PurchaseContext.class::cast) + .or(() -> subscriptionRepository.findDescriptorByReservationId(reservationId)); + } +} diff --git a/src/main/java/alfio/manager/PurchaseContextSearchManager.java b/src/main/java/alfio/manager/PurchaseContextSearchManager.java new file mode 100644 index 0000000000..f072a31e6b --- /dev/null +++ b/src/main/java/alfio/manager/PurchaseContextSearchManager.java @@ -0,0 +1,58 @@ +/** + * 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.model.Event; +import alfio.model.PurchaseContext; +import alfio.model.TicketReservation; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.repository.TicketSearchRepository; +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +@Component +@Transactional(readOnly = true) +@AllArgsConstructor +public class PurchaseContextSearchManager { + + private final TicketSearchRepository ticketSearchRepository; + + public Pair, Integer> findAllReservationsFor(PurchaseContext purchaseContext, Integer page, String search, List status) { + final int pageSize = 50; + int offset = page == null ? 0 : page * pageSize; + String toSearch = StringUtils.trimToNull(search); + toSearch = toSearch == null ? null : ("%" + toSearch + "%"); + List toFilter = (status == null || status.isEmpty() ? Arrays.asList(TicketReservation.TicketReservationStatus.values()) : status).stream().map(TicketReservation.TicketReservationStatus::toString).collect(toList()); + if(purchaseContext.getType() == PurchaseContext.PurchaseContextType.event) { + var event = (Event)purchaseContext; + List reservationsForEvent = ticketSearchRepository.findReservationsForEvent(event.getId(), offset, pageSize, toSearch, toFilter); + return Pair.of(reservationsForEvent, ticketSearchRepository.countReservationsForEvent(event.getId(), toSearch, toFilter)); + } else { + var subscription = (SubscriptionDescriptor) purchaseContext; + List reservationsForSubscription = ticketSearchRepository.findReservationsForSubscription(subscription.getId(), offset, pageSize, toSearch, toFilter); + return Pair.of(reservationsForSubscription, ticketSearchRepository.countReservationsForSubscription(subscription.getId(), toSearch, toFilter)); + } + } +} diff --git a/src/main/java/alfio/manager/SameCountryValidator.java b/src/main/java/alfio/manager/SameCountryValidator.java index a1414121b4..d1aef9d59b 100644 --- a/src/main/java/alfio/manager/SameCountryValidator.java +++ b/src/main/java/alfio/manager/SameCountryValidator.java @@ -17,7 +17,9 @@ package alfio.manager; import alfio.manager.system.ConfigurationManager; +import alfio.model.Event; import alfio.model.EventAndOrganizationId; +import alfio.model.PurchaseContext; import alfio.model.VatDetail; import ch.digitalfondue.vatchecker.EUVatCheckResponse; import ch.digitalfondue.vatchecker.EUVatChecker; @@ -33,7 +35,7 @@ public class SameCountryValidator implements Predicate { private final ConfigurationManager configurationManager; private final ExtensionManager extensionManager; - private final EventAndOrganizationId eventAndOrganizationId; + private final PurchaseContext purchaseContext; private final String ticketReservationId; private final EuVatChecker checker; private final EUVatChecker client = new EUVatChecker(); @@ -42,13 +44,13 @@ public class SameCountryValidator implements Predicate { public boolean test(String vatNr) { if(StringUtils.isEmpty(vatNr)) { - log.warn("empty VAT number received for organizationId {}", eventAndOrganizationId.getOrganizationId()); + log.warn("empty VAT number received for organizationId {}", purchaseContext.getOrganizationId()); } - String organizerCountry = EuVatChecker.organizerCountry(configurationManager, eventAndOrganizationId); + String organizerCountry = EuVatChecker.organizerCountry(configurationManager, purchaseContext); - if(!EuVatChecker.validationEnabled(configurationManager, eventAndOrganizationId)) { - log.warn("VAT checking is not enabled for organizationId {} or country not defined ({})", eventAndOrganizationId.getOrganizationId(), organizerCountry); + if(!EuVatChecker.validationEnabled(configurationManager, purchaseContext)) { + log.warn("VAT checking is not enabled for organizationId {} or country not defined ({})", purchaseContext.getOrganizationId(), organizerCountry); return false; } @@ -57,11 +59,11 @@ public boolean test(String vatNr) { boolean valid = validStrict; if(!valid && StringUtils.isNotBlank(vatNr)) { - valid = extensionManager.handleTaxIdValidation(eventAndOrganizationId.getId(), vatNr, organizerCountry); + valid = extensionManager.handleTaxIdValidation(purchaseContext, vatNr, organizerCountry); } if(valid && StringUtils.isNotEmpty(ticketReservationId)) { VatDetail detail = new VatDetail(vatNr, organizerCountry, true, "", "", validStrict ? VatDetail.Type.VIES : VatDetail.Type.FORMAL, false); - checker.logSuccessfulValidation(detail, ticketReservationId, eventAndOrganizationId.getId()); + checker.logSuccessfulValidation(detail, ticketReservationId, purchaseContext.event().map(Event::getId).orElse(null)); } return valid; } diff --git a/src/main/java/alfio/manager/SpecialPriceManager.java b/src/main/java/alfio/manager/SpecialPriceManager.java index f7e55c6fd2..0aec6a04d9 100644 --- a/src/main/java/alfio/manager/SpecialPriceManager.java +++ b/src/main/java/alfio/manager/SpecialPriceManager.java @@ -18,7 +18,6 @@ import alfio.manager.i18n.I18nManager; import alfio.manager.i18n.MessageSourceManager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.modification.SendCodeModification; @@ -111,9 +110,9 @@ public boolean sendCodeToAssignee(List input, String event Validate.isTrue(!eventLanguages.isEmpty(), "No locales have been defined for the event. Please check the configuration"); ContentLanguage defaultLocale = eventLanguages.contains(ContentLanguage.ENGLISH) ? ContentLanguage.ENGLISH : eventLanguages.get(0); set.forEach(m -> { - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); Locale locale = LocaleUtil.forLanguageTag(StringUtils.defaultString(StringUtils.trimToNull(m.getLanguage()), defaultLocale.getLanguage())); - var usePartnerCode = configurationManager.getFor(USE_PARTNER_CODE_INSTEAD_OF_PROMOTIONAL, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + var usePartnerCode = configurationManager.getFor(USE_PARTNER_CODE_INSTEAD_OF_PROMOTIONAL, event.getConfigurationLevel()).getValueAsBooleanOrDefault(); var promoCodeDescription = messageSource.getMessage("show-event.promo-code-type."+(usePartnerCode ? "partner" : "promotional"), null, null, locale); Map model = TemplateResource.prepareModelForSendReservedCode(organization, event, m, eventManager.getEventUrl(event), promoCodeDescription); notificationManager.sendSimpleEmail(event, diff --git a/src/main/java/alfio/manager/SubscriptionManager.java b/src/main/java/alfio/manager/SubscriptionManager.java new file mode 100644 index 0000000000..3ee9ff4d14 --- /dev/null +++ b/src/main/java/alfio/manager/SubscriptionManager.java @@ -0,0 +1,197 @@ +/** + * 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.model.AllocationStatus; +import alfio.model.modification.SubscriptionDescriptorModification; +import alfio.model.subscription.EventSubscriptionLink; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.model.subscription.SubscriptionDescriptorWithStatistics; +import alfio.repository.SubscriptionRepository; +import lombok.AllArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.apache.commons.lang3.Validate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Stream; + +@Component +@Transactional +@AllArgsConstructor +@Log4j2 +public class SubscriptionManager { + + private final SubscriptionRepository subscriptionRepository; + private final NamedParameterJdbcTemplate jdbcTemplate; + + public List findAll(int organizationId) { + return subscriptionRepository.findAllByOrganizationIds(organizationId); + } + + public Optional createSubscriptionDescriptor(SubscriptionDescriptorModification subscriptionDescriptor) { + var id = UUID.randomUUID(); + int maxEntries = Objects.requireNonNullElse(subscriptionDescriptor.getMaxEntries(), -1); + int result = subscriptionRepository.createSubscriptionDescriptor( + id, + subscriptionDescriptor.getTitle(), + subscriptionDescriptor.getDescription(), + Objects.requireNonNullElse(subscriptionDescriptor.getMaxAvailable(), -1), + subscriptionDescriptor.getOnSaleFrom(), + subscriptionDescriptor.getOnSaleTo(), + subscriptionDescriptor.getPriceCts(), + subscriptionDescriptor.getVat(), + subscriptionDescriptor.getVatStatus(), + subscriptionDescriptor.getCurrency(), + Boolean.TRUE.equals(subscriptionDescriptor.getIsPublic()), + subscriptionDescriptor.getOrganizationId(), + + maxEntries, + subscriptionDescriptor.getValidityType(), + subscriptionDescriptor.getValidityTimeUnit(), + subscriptionDescriptor.getValidityUnits(), + subscriptionDescriptor.getValidityFrom(), + subscriptionDescriptor.getValidityTo(), + subscriptionDescriptor.getUsageType(), + + subscriptionDescriptor.getTermsAndConditionsUrl(), + subscriptionDescriptor.getPrivacyPolicyUrl(), + subscriptionDescriptor.getFileBlobId(), + subscriptionDescriptor.getPaymentProxies(), + UUID.randomUUID().toString(), + subscriptionDescriptor.getTimeZone().toString()); + + if(result != 1) { + return Optional.empty(); + } + + // pre-generate subscriptions if descriptor has a limited quantity + if(maxEntries > 0) { + preGenerateSubscriptions(subscriptionDescriptor, id, maxEntries); + } + + return Optional.of(id); + } + + private void preGenerateSubscriptions(SubscriptionDescriptorModification subscriptionDescriptor, UUID subscriptionDescriptorId, int quantity) { + var results = jdbcTemplate.batchUpdate(subscriptionRepository.batchCreateSubscription(), Stream.generate(UUID::randomUUID) + .limit(quantity) + .map(subscriptionId -> new MapSqlParameterSource("id", subscriptionId) + .addValue("subscriptionDescriptorId", subscriptionDescriptorId) + .addValue("maxUsage", subscriptionDescriptor.getMaxEntries()) + .addValue("validFrom", toOffsetDateTime(subscriptionDescriptor.getValidityFrom())) + .addValue("validTo", toOffsetDateTime(subscriptionDescriptor.getValidityTo())) + .addValue("srcPriceCts", subscriptionDescriptor.getPriceCts()) + .addValue("currency", subscriptionDescriptor.getCurrency()) + .addValue("organizationId", subscriptionDescriptor.getOrganizationId()) + .addValue("status", AllocationStatus.FREE.name()) + .addValue("reservationId", null) + ).toArray(MapSqlParameterSource[]::new)); + var added = Arrays.stream(results).sum(); + if(added != quantity) { + log.warn("wanted to generate {} subscriptions, got {} instead", quantity, added); + throw new IllegalStateException("Cannot set max availability"); + } + } + + private static OffsetDateTime toOffsetDateTime(ZonedDateTime in) { + if(in == null) { + return null; + } + return in.withZoneSameInstant(ZoneId.of("UTC")).toOffsetDateTime(); + } + + public Optional updateSubscriptionDescriptor(SubscriptionDescriptorModification subscriptionDescriptor) { + + return subscriptionRepository.findOne(subscriptionDescriptor.getId(), subscriptionDescriptor.getOrganizationId()) + .flatMap(original -> { + var maxAvailable = subscriptionDescriptor.getMaxAvailable(); + int result = subscriptionRepository.updateSubscriptionDescriptor( + subscriptionDescriptor.getTitle(), + subscriptionDescriptor.getDescription(), + Objects.requireNonNullElse(maxAvailable, -1), + subscriptionDescriptor.getOnSaleFrom(), + subscriptionDescriptor.getOnSaleTo(), + subscriptionDescriptor.getPriceCts(), + subscriptionDescriptor.getVat(), + subscriptionDescriptor.getVatStatus(), + subscriptionDescriptor.getCurrency(), + Boolean.TRUE.equals(subscriptionDescriptor.getIsPublic()), + + Objects.requireNonNullElse(subscriptionDescriptor.getMaxEntries(), -1), + subscriptionDescriptor.getValidityType(), + subscriptionDescriptor.getValidityTimeUnit(), + subscriptionDescriptor.getValidityUnits(), + subscriptionDescriptor.getValidityFrom(), + subscriptionDescriptor.getValidityTo(), + subscriptionDescriptor.getUsageType(), + + subscriptionDescriptor.getTermsAndConditionsUrl(), + subscriptionDescriptor.getPrivacyPolicyUrl(), + subscriptionDescriptor.getFileBlobId(), + subscriptionDescriptor.getPaymentProxies(), + + subscriptionDescriptor.getId(), + original.getOrganizationId(), + subscriptionDescriptor.getTimeZone().toString() + ); + + if(maxAvailable > 0 && maxAvailable > original.getMaxAvailable()) { + preGenerateSubscriptions(subscriptionDescriptor, subscriptionDescriptor.getId(), maxAvailable - original.getMaxAvailable()); + } else if(maxAvailable < original.getMaxAvailable()) { + int amount = original.getMaxAvailable() - Math.max(maxAvailable, 0); + int invalidated = subscriptionRepository.invalidateSubscriptions(subscriptionDescriptor.getId(), amount); + Validate.isTrue(amount == invalidated, "Cannot invalidate existing subscriptions. (wanted: %d got: %d)", amount, invalidated); + } + return result == 1 ? Optional.of(subscriptionDescriptor.getId()) : Optional.empty(); + }); + } + + public Optional findOne(UUID id, int organizationId) { + return subscriptionRepository.findOne(id, organizationId); + } + + public boolean setPublicStatus(UUID id, int organizationId, boolean isPublic) { + return subscriptionRepository.setPublicStatus(id, organizationId, isPublic) == 1; + } + + public List getActivePublicSubscriptionsDescriptor(ZonedDateTime from) { + return subscriptionRepository.findAllActiveAndPublic(from); + } + + public Optional getSubscriptionById(UUID id) { + return subscriptionRepository.findOne(id); + } + + public List loadSubscriptionsWithStatistics(int organizationId) { + return subscriptionRepository.findAllWithStatistics(organizationId); + } + + public int linkSubscriptionToEvent(UUID subscriptionId, int eventId, int organizationId, int pricePerTicket) { + return subscriptionRepository.linkSubscriptionAndEvent(subscriptionId, eventId, pricePerTicket, organizationId); + } + + public List getLinkedEvents(int organizationId, UUID id) { + return subscriptionRepository.findLinkedEvents(organizationId, id); + } +} diff --git a/src/main/java/alfio/manager/TicketReservationManager.java b/src/main/java/alfio/manager/TicketReservationManager.java index 950ae837b7..b831996aed 100644 --- a/src/main/java/alfio/manager/TicketReservationManager.java +++ b/src/main/java/alfio/manager/TicketReservationManager.java @@ -47,6 +47,9 @@ import alfio.model.modification.TicketReservationWithOptionalCodeModification; import alfio.model.result.ErrorCode; import alfio.model.result.Result; +import alfio.model.subscription.Subscription; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.model.subscription.SubscriptionPriceContainer; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; import alfio.model.transaction.capabilities.OfflineProcessor; @@ -98,8 +101,7 @@ import static alfio.model.PromoCodeDiscount.categoriesOrNull; import static alfio.model.TicketReservation.TicketReservationStatus.*; import static alfio.model.system.ConfigurationKeys.*; -import static alfio.util.MonetaryUtil.formatCents; -import static alfio.util.MonetaryUtil.unitToCents; +import static alfio.util.MonetaryUtil.*; import static alfio.util.Wrappers.optionally; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; @@ -152,6 +154,8 @@ public class TicketReservationManager { private final Json json; private final BillingDocumentManager billingDocumentManager; private final ClockProvider clockProvider; + private final PurchaseContextManager purchaseContextManager; + private final SubscriptionRepository subscriptionRepository; public static class NotEnoughTicketsException extends RuntimeException { @@ -202,7 +206,9 @@ public TicketReservationManager(EventRepository eventRepository, NamedParameterJdbcTemplate jdbcTemplate, Json json, BillingDocumentManager billingDocumentManager, - ClockProvider clockProvider) { + ClockProvider clockProvider, + PurchaseContextManager purchaseContextManager, + SubscriptionRepository subscriptionRepository) { this.eventRepository = eventRepository; this.organizationRepository = organizationRepository; this.ticketRepository = ticketRepository; @@ -238,6 +244,39 @@ public TicketReservationManager(EventRepository eventRepository, this.json = json; this.billingDocumentManager = billingDocumentManager; this.clockProvider = clockProvider; + this.purchaseContextManager = purchaseContextManager; + this.subscriptionRepository = subscriptionRepository; + } + + private String createSubscriptionReservation(SubscriptionDescriptor subscriptionDescriptor, Date reservationExpiration, Locale locale) throws CannotProceedWithPayment, NotEnoughTicketsException { + String reservationId = UUID.randomUUID().toString(); + ticketReservationRepository.createNewReservation(reservationId, + subscriptionDescriptor.now(clockProvider), + reservationExpiration, null, + locale.getLanguage(), + subscriptionDescriptor.event().map(Event::getId).orElse(null), + subscriptionDescriptor.getVat(), + subscriptionDescriptor.getVatStatus() == PriceContainer.VatStatus.INCLUDED, + subscriptionDescriptor.getCurrency(), + subscriptionDescriptor.getOrganizationId()); + if(subscriptionDescriptor.getMaxAvailable() > 0) { + var optionalSubscription = subscriptionRepository.selectFreeSubscription(subscriptionDescriptor.getId()); + if(optionalSubscription.isEmpty()) { + throw new NotEnoughTicketsException(); + } + subscriptionRepository.bindSubscriptionToReservation(reservationId, AllocationStatus.PENDING, optionalSubscription.get()); + } else { + subscriptionRepository.createSubscription(UUID.randomUUID(), subscriptionDescriptor.getId(), reservationId, subscriptionDescriptor.getMaxEntries(), + subscriptionDescriptor.getValidityFrom(), subscriptionDescriptor.getValidityTo(), subscriptionDescriptor.getPrice(), subscriptionDescriptor.getCurrency(), subscriptionDescriptor.getOrganizationId(), AllocationStatus.PENDING); + } + var totalPrice = totalReservationCostWithVAT(reservationId).getLeft(); + var vatStatus = subscriptionDescriptor.getVatStatus(); + ticketReservationRepository.updateBillingData(subscriptionDescriptor.getVatStatus(), calculateSrcPrice(vatStatus, totalPrice), totalPrice.getPriceWithVAT(), totalPrice.getVAT(), Math.abs(totalPrice.getDiscount()), subscriptionDescriptor.getCurrency(), null, null, false, reservationId); + auditingRepository.insert(reservationId, null, subscriptionDescriptor.event().map(Event::getId).orElse(null), Audit.EventType.RESERVATION_CREATE, new Date(), Audit.EntityType.RESERVATION, reservationId); + if (!canProceedWithPayment(subscriptionDescriptor, totalPrice, reservationId)) { + throw new CannotProceedWithPayment("No payment method applicable for purchase context " + subscriptionDescriptor.getType() + " with public id " + subscriptionDescriptor.getPublicIdentifier()); + } + return reservationId; } /** @@ -258,7 +297,7 @@ public String createTicketReservation(Event event, Locale locale, boolean forWaitingQueue) throws NotEnoughTicketsException, MissingSpecialPriceTokenException, InvalidSpecialPriceTokenException { String reservationId = UUID.randomUUID().toString(); - + Optional discount = promotionCodeDiscount .flatMap(promoCodeDiscount -> promoCodeDiscountRepository.findPromoCodeInEventOrOrganization(event.getId(), promoCodeDiscount)); @@ -271,7 +310,9 @@ public String createTicketReservation(Event event, event.getId(), event.getVat(), event.isVatIncluded(), - event.getCurrency()); + event.getCurrency(), + event.getOrganizationId()); + list.forEach(t -> reserveTicketsForCategory(event, reservationId, t, locale, forWaitingQueue, discount.orElse(null), dynamicDiscount.orElse(null))); int ticketCount = list @@ -339,15 +380,6 @@ private int calculateSrcPrice(PriceContainer.VatStatus vatStatus, TotalPrice tot + Math.abs(totalPrice.getDiscount()); } - public Pair, Integer> findAllReservationsInEvent(int eventId, Integer page, String search, List status) { - final int pageSize = 50; - int offset = page == null ? 0 : page * pageSize; - String toSearch = StringUtils.trimToNull(search); - toSearch = toSearch == null ? null : ("%" + toSearch + "%"); - List toFilter = (status == null || status.isEmpty() ? Arrays.asList(TicketReservationStatus.values()) : status).stream().map(TicketReservationStatus::toString).collect(toList()); - List reservationsForEvent = ticketSearchRepository.findReservationsForEvent(eventId, offset, pageSize, toSearch, toFilter); - return Pair.of(reservationsForEvent, ticketSearchRepository.countReservationsForEvent(eventId, toSearch, toFilter)); - } void reserveTicketsForCategory(Event event, String reservationId, @@ -517,7 +549,7 @@ public PaymentResult performPayment(PaymentSpecification spec, PaymentMethod paymentMethod) { PaymentProxy paymentProxy = evaluatePaymentProxy(proxy, reservationCost); - if(!acquireGroupMembers(spec.getReservationId(), spec.getEvent())) { + if(!acquireGroupMembers(spec.getReservationId(), spec.getPurchaseContext())) { groupManager.deleteWhitelistedTicketsForReservation(spec.getReservationId()); return PaymentResult.failed("error.STEP2_WHITELIST"); } @@ -577,7 +609,7 @@ public PaymentResult performPayment(PaymentSpecification spec, } private boolean paymentMethodIsBlacklisted(PaymentMethod paymentMethod, PaymentSpecification spec) { - return configurationManager.getBlacklistedMethodsForReservation(spec.getEvent(), findCategoryIdsInReservation(spec.getReservationId())) + return configurationManager.getBlacklistedMethodsForReservation(spec.getPurchaseContext(), findCategoryIdsInReservation(spec.getReservationId())) .stream().anyMatch(m -> m == paymentMethod); } @@ -588,7 +620,7 @@ public Collection findCategoryIdsInReservation(String reservationId) { .collect(Collectors.toSet()); } - public boolean cancelPendingPayment(String reservationId, Event event) { + public boolean cancelPendingPayment(String reservationId, PurchaseContext purchaseContext) { var optionalReservation = findById(reservationId); if(optionalReservation.isEmpty()) { return false; @@ -600,12 +632,12 @@ public boolean cancelPendingPayment(String reservationId, Event event) { } Transaction transaction = optionalTransaction.get(); boolean remoteDeleteResult = paymentManager.lookupProviderByTransactionAndCapabilities(transaction, List.of(ServerInitiatedTransaction.class)) - .map(provider -> ((ServerInitiatedTransaction)provider).discardTransaction(optionalTransaction.get(), event)) + .map(provider -> ((ServerInitiatedTransaction)provider).discardTransaction(optionalTransaction.get(), purchaseContext)) .orElse(true); if(remoteDeleteResult) { reTransitionToPending(reservationId); - auditingRepository.insert(reservationId, null, event.getId(), RESET_PAYMENT, new Date(), RESERVATION, reservationId); + auditingRepository.insert(reservationId, null, purchaseContext.event().map(Event::getId).orElse(null), RESET_PAYMENT, new Date(), RESERVATION, reservationId); return true; } log.warn("Cannot delete payment with ID {} for reservation {}", transaction.getPaymentId(), reservationId); @@ -621,7 +653,7 @@ private void transitionToComplete(PaymentSpecification spec, TotalPrice reservat } private void generateInvoiceNumber(PaymentSpecification spec, TotalPrice reservationCost) { - if(!reservationCost.requiresPayment() || !spec.isInvoiceRequested() || !configurationManager.hasAllConfigurationsForInvoice(spec.getEvent())) { + if(!reservationCost.requiresPayment() || !spec.isInvoiceRequested() || !configurationManager.hasAllConfigurationsForInvoice(spec.getPurchaseContext())) { return; } @@ -631,14 +663,14 @@ private void generateInvoiceNumber(PaymentSpecification spec, TotalPrice reserva optionalInvoiceNumber.ifPresent(invoiceNumber -> { List> modifications = List.of(Map.of("invoiceNumber", invoiceNumber)); - auditingRepository.insert(reservationId, null, spec.getEvent().getId(), EXTERNAL_INVOICE_NUMBER, new Date(), RESERVATION, reservationId, modifications); + auditingRepository.insert(reservationId, null, spec.getPurchaseContext(), EXTERNAL_INVOICE_NUMBER, new Date(), RESERVATION, reservationId, modifications); }); String invoiceNumber = optionalInvoiceNumber.orElseGet(() -> { - int invoiceSequence = invoiceSequencesRepository.lockReservationForUpdate(spec.getEvent().getOrganizationId()); - invoiceSequencesRepository.incrementSequenceFor(spec.getEvent().getOrganizationId()); + int invoiceSequence = invoiceSequencesRepository.lockReservationForUpdate(spec.getPurchaseContext().getOrganizationId()); + invoiceSequencesRepository.incrementSequenceFor(spec.getPurchaseContext().getOrganizationId()); String pattern = configurationManager - .getFor(ConfigurationKeys.INVOICE_NUMBER_PATTERN, ConfigurationLevel.event(spec.getEvent())) + .getFor(ConfigurationKeys.INVOICE_NUMBER_PATTERN, spec.getPurchaseContext().getConfigurationLevel()) .getValueOrDefault("%d"); return String.format(ObjectUtils.firstNonNull(StringUtils.trimToNull(pattern), "%d"), invoiceSequence); }); @@ -694,8 +726,8 @@ private boolean initPaymentProcess(TotalPrice reservationCost, PaymentProxy paym return true; } - private boolean acquireGroupMembers(String reservationId, EventAndOrganizationId event) { - List linkedGroups = groupManager.getLinksForEvent(event.getId()); + private boolean acquireGroupMembers(String reservationId, PurchaseContext purchaseContext) { + List linkedGroups = purchaseContext.event().map(event -> groupManager.getLinksForEvent(event.getId())).orElse(List.of()); if(!linkedGroups.isEmpty()) { List ticketsInReservation = ticketRepository.findTicketsInReservation(reservationId); return Boolean.TRUE.equals(requiresNewTransactionTemplate.execute(status -> @@ -734,7 +766,7 @@ public void confirmOfflinePayment(Event event, String reservationId, String user 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.getId()); + extensionManager.handleReservationConfirmation(finalReservation, ticketReservationRepository.getBillingDetailsForReservation(reservationId), event); } void registerAlfioTransaction(Event event, String reservationId, PaymentProxy paymentProxy) { @@ -763,55 +795,69 @@ void registerAlfioTransaction(Event event, String reservationId, PaymentProxy pa } - public void sendConfirmationEmail(Event event, TicketReservation ticketReservation, Locale language, String username) { + public void sendConfirmationEmail(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, String username) { String reservationId = ticketReservation.getId(); - OrderSummary summary = orderSummaryForReservationId(reservationId, event); + OrderSummary summary = orderSummaryForReservationId(reservationId, purchaseContext); - Map reservationEmailModel = prepareModelForReservationEmail(event, ticketReservation); + Map initialModel; + TemplateResource templateResource; + if(purchaseContext.getType() == PurchaseContext.PurchaseContextType.subscription) { + var firstSubscription = subscriptionRepository.findSubscriptionsByReservationId(reservationId).stream().findFirst().orElseThrow(); + initialModel = Map.of("pin", firstSubscription.getPin(), "subscriptionId", firstSubscription.getId()); + templateResource = TemplateResource.CONFIRMATION_EMAIL_SUBSCRIPTION; + } else { + initialModel = Map.of(); + templateResource = TemplateResource.CONFIRMATION_EMAIL; + } + + Map reservationEmailModel = prepareModelForReservationEmail(purchaseContext, ticketReservation, getVAT(purchaseContext), summary, initialModel); List attachments = Collections.emptyList(); - if (configurationManager.canGenerateReceiptOrInvoiceToCustomer(event)) { // https://github.com/alfio-event/alf.io/issues/573 - attachments = generateAttachmentForConfirmationEmail(event, ticketReservation, language, summary, username); + if (configurationManager.canGenerateReceiptOrInvoiceToCustomer(purchaseContext)) { // https://github.com/alfio-event/alf.io/issues/573 + attachments = generateAttachmentForConfirmationEmail(purchaseContext, ticketReservation, language, summary, username); } - notificationManager.sendSimpleEmail(event, ticketReservation.getId(), ticketReservation.getEmail(), messageSourceManager.getMessageSourceForEvent(event).getMessage("reservation-email-subject", - new Object[]{getShortReservationID(event, ticketReservation), event.getDisplayName()}, language), - () -> templateManager.renderTemplate(event, TemplateResource.CONFIRMATION_EMAIL, reservationEmailModel, language), + + var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); + var localizedType = messageSource.getMessage("purchase-context."+purchaseContext.getType(), null, language); + notificationManager.sendSimpleEmail(purchaseContext, ticketReservation.getId(), ticketReservation.getEmail(), messageSource.getMessage("reservation-email-subject", + new Object[]{getShortReservationID(purchaseContext, ticketReservation), purchaseContext.getTitle().get(language.getLanguage()), localizedType}, language), + () -> templateManager.renderTemplate(purchaseContext, templateResource, reservationEmailModel, language), attachments); } - private List generateAttachmentForConfirmationEmail(Event event, + 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(event, ticketReservation, language, type, username, summary); + return billingDocumentManager.generateBillingDocumentAttachment(purchaseContext, ticketReservation, language, type, username, summary); } return List.of(); } - public void sendReservationCompleteEmailToOrganizer(Event event, TicketReservation ticketReservation, Locale language, String username) { - Organization organization = organizationRepository.getById(event.getOrganizationId()); - List cc = notificationManager.getCCForEventOrganizer(event); + 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(event, ticketReservation); + Map reservationEmailModel = prepareModelForReservationEmail(purchaseContext, ticketReservation); String reservationId = ticketReservation.getId(); - OrderSummary summary = orderSummaryForReservationId(reservationId, event); + OrderSummary summary = orderSummaryForReservationId(reservationId, purchaseContext); List attachments = Collections.emptyList(); - if (!configurationManager.canGenerateReceiptOrInvoiceToCustomer(event) || configurationManager.isInvoiceOnly(event)) { // https://github.com/alfio-event/alf.io/issues/573 - attachments = generateAttachmentForConfirmationEmail(event, ticketReservation, language, summary, username); + 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(event, ticketReservation); - notificationManager.sendSimpleEmail(event, null, organization.getEmail(), cc, "Reservation complete " + shortReservationID, - () -> templateManager.renderTemplate(event, TemplateResource.CONFIRMATION_EMAIL_FOR_ORGANIZER, reservationEmailModel, language), + 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); } @@ -819,14 +865,14 @@ private static boolean mustGenerateBillingDocument(OrderSummary summary, TicketR return !summary.getFree() && (!summary.getNotYetPaid() || (summary.getWaitingForPayment() && ticketReservation.isInvoiceRequested())); } - private List generateBillingDocumentAttachment(EventAndOrganizationId event, + private List generateBillingDocumentAttachment(PurchaseContext purchaseContext, TicketReservation ticketReservation, Locale language, Map billingDocumentModel, BillingDocument.Type documentType) { Map model = new HashMap<>(); model.put("reservationId", ticketReservation.getId()); - model.put("eventId", Integer.toString(event.getId())); + purchaseContext.event().map(event -> model.put("eventId", Integer.toString(event.getId()))); model.put("language", json.asJsonString(language)); model.put("reservationEmailModel", json.asJsonString(billingDocumentModel));//ticketReservation.getHasInvoiceNumber() switch (documentType) { @@ -862,34 +908,35 @@ public void deleteOfflinePayment(Event event, String reservationId, boolean expi } } - private String getReservationEmailSubject(Event event, Locale reservationLanguage, String key, String id) { - return messageSourceManager.getMessageSourceForEvent(event).getMessage(key, new Object[]{id, event.getDisplayName()}, reservationLanguage); + 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(Event event, String reservationId, String username) { + public void issueCreditNoteForReservation(PurchaseContext purchaseContext, String reservationId, String username) { TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); ticketReservationRepository.updateReservationStatus(reservationId, TicketReservationStatus.CREDIT_NOTE_ISSUED.toString()); - auditingRepository.insert(reservationId, userRepository.nullSafeFindIdByUserName(username).orElse(null), event.getId(), Audit.EventType.CREDIT_NOTE_ISSUED, new Date(), RESERVATION, reservationId); - Map model = prepareModelForReservationEmail(event, reservation); - BillingDocument billingDocument = billingDocumentManager.createBillingDocument(event, reservation, username, BillingDocument.Type.CREDIT_NOTE, orderSummaryForReservation(reservation, event)); - notificationManager.sendSimpleEmail(event, + auditingRepository.insert(reservationId, userRepository.nullSafeFindIdByUserName(username).orElse(null), purchaseContext, Audit.EventType.CREDIT_NOTE_ISSUED, new Date(), RESERVATION, reservationId); + Map model = prepareModelForReservationEmail(purchaseContext, reservation); + BillingDocument billingDocument = billingDocumentManager.createBillingDocument(purchaseContext, reservation, username, BillingDocument.Type.CREDIT_NOTE, orderSummaryForReservation(reservation, purchaseContext)); + notificationManager.sendSimpleEmail(purchaseContext, reservationId, reservation.getEmail(), - getReservationEmailSubject(event, getReservationLocale(reservation), "credit-note-issued-email-subject", reservation.getId()), - () -> templateManager.renderTemplate(event, TemplateResource.CREDIT_NOTE_ISSUED_EMAIL, model, getReservationLocale(reservation)), - generateBillingDocumentAttachment(event, reservation, getReservationLocale(reservation), billingDocument.getModel(), CREDIT_NOTE) + 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) ); } @Transactional(readOnly = true) - public Map prepareModelForReservationEmail(Event event, TicketReservation reservation, Optional vat, OrderSummary summary) { - Organization organization = organizationRepository.getById(event.getOrganizationId()); - String baseUrl = baseUrl(event); + public Map prepareModelForReservationEmail(PurchaseContext purchaseContext, TicketReservation reservation, Optional vat, OrderSummary summary, Map initialOptions) { + Organization organization = organizationRepository.getById(purchaseContext.getOrganizationId()); + String baseUrl = baseUrl(purchaseContext); String reservationUrl = reservationUrl(reservation.getId()); - String reservationShortID = getShortReservationID(event, reservation); + String reservationShortID = getShortReservationID(purchaseContext, reservation); - var bankingInfo = configurationManager.getFor(Set.of(INVOICE_ADDRESS, BANK_ACCOUNT_NR, BANK_ACCOUNT_OWNER), ConfigurationLevel.event(event)); + 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(); @@ -906,15 +953,17 @@ public Map prepareModelForReservationEmail(Event event, TicketRe } else { ticketsWithCategory = Collections.emptyList(); } - var initialOptions = extensionManager.handleReservationEmailCustomText(event, reservation, ticketReservationRepository.getAdditionalInfo(reservation.getId())) + Map baseModel = new HashMap<>(); + baseModel.putAll(initialOptions); + baseModel.putAll(extensionManager.handleReservationEmailCustomText(purchaseContext, reservation, ticketReservationRepository.getAdditionalInfo(reservation.getId())) .map(CustomEmailText::toMap) - .orElse(Map.of()); - Map model = TemplateResource.prepareModelForConfirmationEmail(organization, event, reservation, vat, ticketsWithCategory, summary, baseUrl, reservationUrl, reservationShortID, invoiceAddress, bankAccountNr, bankAccountOwner, initialOptions); + .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(event, reservation)); + model.put("publicId", configurationManager.getPublicReservationID(purchaseContext, reservation)); model.put("invoicingAdditionalInfo", loadAdditionalInfo(reservation.getId()).getInvoicingAdditionalInfo()); return model; } @@ -924,10 +973,10 @@ public TicketReservationAdditionalInfo loadAdditionalInfo(String reservationId) } @Transactional(readOnly = true) - public Map prepareModelForReservationEmail(Event event, TicketReservation reservation) { - Optional vat = getVAT(event); - OrderSummary summary = orderSummaryForReservationId(reservation.getId(), event); - return prepareModelForReservationEmail(event, reservation, vat, summary); + public Map prepareModelForReservationEmail(PurchaseContext purchaseContext, TicketReservation reservation) { + Optional vat = getVAT(purchaseContext); + OrderSummary summary = orderSummaryForReservationId(reservation.getId(), purchaseContext); + return prepareModelForReservationEmail(purchaseContext, reservation, vat, summary, Map.of()); } private void transitionToInPayment(PaymentSpecification spec) { @@ -956,14 +1005,14 @@ public static boolean hasValidOfflinePaymentWaitingPeriod(PaymentContext context * ValidPaymentMethod should be configured in organisation and event. And if even already started then event should not have PaymentProxy.OFFLINE as only payment method * * @param paymentMethodDTO - * @param event + * @param purchaseContext * @param configurationManager * @return */ - public static boolean isValidPaymentMethod(PaymentManager.PaymentMethodDTO paymentMethodDTO, Event event, ConfigurationManager configurationManager) { + public static boolean isValidPaymentMethod(PaymentManager.PaymentMethodDTO paymentMethodDTO, PurchaseContext purchaseContext, ConfigurationManager configurationManager) { return paymentMethodDTO.isActive() - && event.getAllowedPaymentProxies().contains(paymentMethodDTO.getPaymentProxy()) - && (!paymentMethodDTO.getPaymentProxy().equals(PaymentProxy.OFFLINE) || hasValidOfflinePaymentWaitingPeriod(new PaymentContext(event), configurationManager)); + && purchaseContext.getAllowedPaymentProxies().contains(paymentMethodDTO.getPaymentProxy()) + && (!paymentMethodDTO.getPaymentProxy().equals(PaymentProxy.OFFLINE) || hasValidOfflinePaymentWaitingPeriod(new PaymentContext(purchaseContext), configurationManager)); } private void reTransitionToPending(String reservationId, boolean deleteTransactions) { @@ -997,86 +1046,80 @@ public Optional> from(String eventName, */ void completeReservation(PaymentSpecification spec, PaymentProxy paymentProxy, boolean sendReservationConfirmationEmail, boolean sendTickets, String username) { String reservationId = spec.getReservationId(); - int eventId = spec.getEvent().getId(); + var purchaseContext = spec.getPurchaseContext(); final TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); 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.getEvent(), sendTickets); - extensionManager.handleReservationConfirmation(reservation, ticketReservationRepository.getBillingDetailsForReservation(reservationId), eventId); + 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, null, eventId, Audit.EventType.RESERVATION_COMPLETE, eventTime, Audit.EntityType.RESERVATION, reservationId); - ticketReservationRepository.updateRegistrationTimestamp(reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getEvent().getZoneId()))); + auditingRepository.insert(reservationId, null, 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, null, eventId, Audit.EventType.TERMS_CONDITION_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("termsAndConditionsUrl", spec.getEvent().getTermsAndConditionsUrl()))); + auditingRepository.insert(reservationId, null, purchaseContext, Audit.EventType.TERMS_CONDITION_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("termsAndConditionsUrl", spec.getPurchaseContext().getTermsAndConditionsUrl()))); } - if(eventHasPrivacyPolicy(spec.getEvent()) && spec.isPrivacyAccepted()) { - auditingRepository.insert(reservationId, null, eventId, Audit.EventType.PRIVACY_POLICY_ACCEPTED, eventTime, Audit.EntityType.RESERVATION, reservationId, singletonList(singletonMap("privacyPolicyUrl", spec.getEvent().getPrivacyPolicyUrl()))); + if(eventHasPrivacyPolicy(spec.getPurchaseContext()) && spec.isPrivacyAccepted()) { + auditingRepository.insert(reservationId, null, 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, spec.getEvent(), locale, username); - sendReservationCompleteEmailToOrganizer(spec.getEvent(), updatedReservation, locale, username); + var t = tickets; + sendConfirmationEmailIfNecessary(updatedReservation, t, purchaseContext, locale, username); + sendReservationCompleteEmailToOrganizer(spec.getPurchaseContext(), updatedReservation, locale, username); } } void sendConfirmationEmailIfNecessary(TicketReservation ticketReservation, List tickets, - Event event, + PurchaseContext purchaseContext, Locale locale, String username) { - var config = configurationManager.getFor(List.of(SEND_RESERVATION_EMAIL_IF_NECESSARY, SEND_TICKETS_AUTOMATICALLY), ConfigurationLevel.event(event)); - 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(event, ticketReservation, locale, username); + if(purchaseContext.getType() == 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() + ) { + sendConfirmationEmail(purchaseContext, ticketReservation, locale, username); + } + } else { + sendConfirmationEmail(purchaseContext, ticketReservation, locale, username); } } - private boolean eventHasPrivacyPolicy(Event event) { + 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, Event event, boolean sendTickets) { - - 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, ConfigurationLevel.event(event)).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()); - }); + 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"); } - List ticketsInReservation = ticketRepository.findTicketsInReservation(reservationId); - Map postUpdateTicket = ticketsInReservation.stream().collect(toMap(Ticket::getId, Function.identity())); - postUpdateTicket.forEach((id, ticket) -> auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticket, Collections.emptyMap(), event.getId())); - - int updatedAS = additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservationId, asStatus); - Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); 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); @@ -1084,6 +1127,7 @@ private List acquireItems(PaymentProxy paymentProxy, String reservationI 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()); if((paymentProxy != PaymentProxy.ADMIN || sendTickets) && configurationManager.getFor(SEND_TICKETS_AUTOMATICALLY, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault()) { sendTicketByEmail(ticket, locale, event, getTicketEmailGenerator(event, reservation, locale)); @@ -1096,6 +1140,36 @@ private List acquireItems(PaymentProxy paymentProxy, String reservationI 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 updatedSubscriptions = subscriptionRepository.updateSubscriptionStatus(reservationId, status, customerName.getFirstName(), customerName.getLastName(), email); + subscriptionRepository.findSubscriptionsByReservationId(reservationId) // at the moment it's safe because there can be only one subscription per reservation + .forEach(subscriptionId -> auditingRepository.insert(reservationId, null, purchaseContext, SUBSCRIPTION_ACQUIRED, new Date(), Audit.EntityType.SUBSCRIPTION, subscriptionId.toString())); + Validate.isTrue(updatedSubscriptions > 0, "must have updated at least one subscription"); + } + + 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()); + }); + } + List ticketsInReservation = ticketRepository.findTicketsInReservation(reservationId); + Map postUpdateTicket = ticketsInReservation.stream().collect(toMap(Ticket::getId, Function.identity())); + postUpdateTicket.forEach((id, ticket) -> auditUpdateTicket(preUpdateTicket.get(id), Collections.emptyMap(), ticket, Collections.emptyMap(), event.getId())); + int updatedAS = additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservationId, asStatus); + Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); + } + public PartialTicketTextGenerator getTicketEmailGenerator(Event event, TicketReservation ticketReservation, Locale ticketLanguage) { return ticket -> { Organization organization = organizationRepository.getById(event.getOrganizationId()); @@ -1129,7 +1203,8 @@ public void cleanupExpiredReservations(Date expirationDate) { if(expiredReservationIds.isEmpty()) { return; } - + + subscriptionRepository.deleteSubscriptionWithReservationId(expiredReservationIds); specialPriceRepository.resetToFreeAndCleanupForReservation(expiredReservationIds); ticketRepository.resetCategoryIdForUnboundedCategories(expiredReservationIds); ticketFieldRepository.deleteAllValuesForReservations(expiredReservationIds); @@ -1144,6 +1219,7 @@ public void cleanupExpiredReservations(Date expirationDate) { reservationIdsByEvent.forEach((eventId, reservations) -> { Event event = eventRepository.findById(eventId); List reservationIds = reservations.stream().map(ReservationIdAndEventId::getId).collect(toList()); + subscriptionRepository.decrementUse(reservationIds); extensionManager.handleReservationsExpiredForEvent(event, reservationIds); billingDocumentRepository.deleteForReservations(reservationIds, eventId); transactionRepository.deleteForReservations(reservationIds); @@ -1236,19 +1312,21 @@ private List> findStuckPaymentsToBeNotified(Date } private static Pair> totalReservationCostWithVAT(PromoCodeDiscount promoCodeDiscount, - Event event, - TicketReservation reservation, - List tickets, - List>> additionalServiceItems) { - - String currencyCode = event.getCurrency(); - List ticketPrices = tickets.stream().map(t -> TicketPriceContainer.from(t, reservation.getVatStatus(), event.getVat(), event.getVatStatus(), promoCodeDiscount)).collect(toList()); + 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, event, additionalServiceItems); + 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), @@ -1257,8 +1335,8 @@ private static Pair> totalReservationCos return Pair.of(price, Optional.ofNullable(promoCodeDiscount)); } - private static Function>, Stream> generateASIPriceContainers(Event event, PromoCodeDiscount discount) { - return p -> p.getValue().stream().map(asi -> AdditionalServiceItemPriceContainer.from(asi, p.getKey(), event, discount)); + private static Function>, Stream> generateASIPriceContainers(PurchaseContext purchaseContext, PromoCodeDiscount discount) { + return p -> p.getValue().stream().map(asi -> AdditionalServiceItemPriceContainer.from(asi, p.getKey(), purchaseContext, discount)); } /** @@ -1272,19 +1350,23 @@ public Pair> totalReservationCostWithVAT } public Pair> totalReservationCostWithVAT(TicketReservation reservation) { - return totalReservationCostWithVAT(eventRepository.findByReservationId(reservation.getId()), reservation, ticketRepository.findTicketsInReservation(reservation.getId())); + return totalReservationCostWithVAT(purchaseContextManager.findByReservationId(reservation.getId()).orElseThrow(), reservation, ticketRepository.findTicketsInReservation(reservation.getId())); } - public Pair> totalReservationCostWithVAT(Event event, TicketReservation reservation, List tickets) { - Optional promoCodeDiscount = Optional.ofNullable(reservation.getPromoCodeDiscountId()) - .map(promoCodeDiscountRepository::findById); - return totalReservationCostWithVAT(promoCodeDiscount.orElse(null), event, reservation, tickets, collectAdditionalServiceItems(reservation.getId(), event)); + 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, Event event) { + private String formatPromoCode(PromoCodeDiscount promoCodeDiscount, List tickets, Locale locale, PurchaseContext purchaseContext) { if(promoCodeDiscount.getCodeType() == CodeType.DYNAMIC) { - return messageSourceManager.getMessageSourceForEvent(event).getMessage("reservation.dynamic.discount.description", null, locale); //we don't expose the internal promo code + 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()); @@ -1304,12 +1386,12 @@ private String formatPromoCode(PromoCodeDiscount promoCodeDiscount, List return promoCodeDiscount.getPromoCode() + " " + formattedDiscountedCategories; } - public OrderSummary orderSummaryForReservationId(String reservationId, Event event) { + public OrderSummary orderSummaryForReservationId(String reservationId, PurchaseContext purchaseContext) { TicketReservation reservation = ticketReservationRepository.findReservationById(reservationId); - return orderSummaryForReservation(reservation, event); + return orderSummaryForReservation(reservation, purchaseContext); } - public OrderSummary orderSummaryForReservation(TicketReservation reservation, Event event) { + public OrderSummary orderSummaryForReservation(TicketReservation reservation, PurchaseContext context) { var totalPriceAndDiscount = totalReservationCostWithVAT(reservation); TotalPrice reservationCost = totalPriceAndDiscount.getLeft(); PromoCodeDiscount discount = totalPriceAndDiscount.getRight().orElse(null); @@ -1320,30 +1402,30 @@ public OrderSummary orderSummaryForReservation(TicketReservation reservation, Ev boolean hasRefund = auditingRepository.countAuditsOfTypeForReservation(reservation.getId(), Audit.EventType.REFUND) > 0; if(hasRefund) { - refundedAmount = paymentManager.getInfo(reservation, event).getPaymentInformation().getRefundedAmount(); + refundedAmount = paymentManager.getInfo(reservation, context).getPaymentInformation().getRefundedAmount(); } var currencyCode = reservation.getCurrencyCode(); return new OrderSummary(reservationCost, - extractSummary(reservation.getId(), reservation.getVatStatus(), event, LocaleUtil.forLanguageTag(reservation.getUserLanguage()), discount, 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(event.getVat()).map(p -> MonetaryUtil.formatCents(MonetaryUtil.unitToCents(p, currencyCode), currencyCode)).orElse(null), + Optional.ofNullable(context.getVat()).map(p -> MonetaryUtil.formatCents(MonetaryUtil.unitToCents(p, currencyCode), currencyCode)).orElse(null), reservation.getVatStatus(), refundedAmount); } List extractSummary(String reservationId, PriceContainer.VatStatus reservationVatStatus, - Event event, Locale locale, PromoCodeDiscount promoCodeDiscount, TotalPrice reservationCost) { + PurchaseContext purchaseContext, Locale locale, PromoCodeDiscount promoCodeDiscount, TotalPrice reservationCost) { List summary = new ArrayList<>(); var currencyCode = reservationCost.getCurrencyCode(); List tickets = ticketRepository.findTicketsInReservation(reservationId).stream() - .map(t -> TicketPriceContainer.from(t, reservationVatStatus, event.getVat(), event.getVatStatus(), promoCodeDiscount)).collect(toList()); - tickets.stream() + .map(t -> TicketPriceContainer.from(t, reservationVatStatus, purchaseContext.getVat(), purchaseContext.getVatStatus(), promoCodeDiscount)).collect(toList()); + purchaseContext.event().ifPresent(event -> tickets.stream() .collect(Collectors.groupingBy(TicketPriceContainer::getCategoryId)) .forEach((categoryId, ticketsByCategory) -> { final int subTotal = ticketsByCategory.stream().mapToInt(TicketPriceContainer::getSummarySrcPriceCts).sum(); @@ -1353,16 +1435,16 @@ List extractSummary(String reservationId, PriceContainer.VatStatus r final int priceBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(singletonList(firstTicket)); String categoryName = ticketCategoryRepository.getByIdAndActive(categoryId, event.getId()).getName(); summary.add(new SummaryRow(categoryName, formatCents(ticketPriceCts, currencyCode), formatCents(priceBeforeVat, currencyCode), ticketsByCategory.size(), formatCents(subTotal, currencyCode), formatCents(subTotalBeforeVat, currencyCode), subTotal, SummaryType.TICKET)); - }); + })); - summary.addAll(streamAdditionalServiceItems(reservationId, event) + summary.addAll(streamAdditionalServiceItems(reservationId, purchaseContext) .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(event, null).apply(entry).collect(toList()); + 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); @@ -1371,42 +1453,84 @@ List extractSummary(String reservationId, PriceContainer.VatStatus r Optional.ofNullable(promoCodeDiscount).ifPresent(promo -> { String formattedSingleAmount = "-" + (DiscountType.isFixedAmount(promo.getDiscountType()) ? formatCents(promo.getDiscountAmount(), currencyCode) : (promo.getDiscountAmount()+"%")); - summary.add(new SummaryRow(formatPromoCode(promo, ticketRepository.findTicketsInReservation(reservationId), locale, event), + summary.add(new SummaryRow(formatPromoCode(promo, ticketRepository.findTicketsInReservation(reservationId), locale, purchaseContext), formattedSingleAmount, formattedSingleAmount, reservationCost.getDiscountAppliedCount(), formatCents(reservationCost.getDiscount(), currencyCode), formatCents(reservationCost.getDiscount(), currencyCode), reservationCost.getDiscount(), promo.isDynamic() ? SummaryType.DYNAMIC_DISCOUNT : SummaryType.PROMOTION_CODE)); }); + // + if(purchaseContext instanceof SubscriptionDescriptor) { + var subscriptions = subscriptionRepository.findSubscriptionsByReservationId(reservationId); + if(!subscriptions.isEmpty()) { + var subscription = subscriptions.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, + subscriptions.size(), + formatCents(priceContainer.getSummarySrcPriceCts() * subscriptions.size(), currencyCode), + formatUnit(priceContainer.getNetPrice().multiply(new BigDecimal(subscriptions.size())), currencyCode), + priceContainer.getSummarySrcPriceCts(), + SummaryType.SUBSCRIPTION + )); + } + } else { + subscriptionRepository.findDescriptorForAppliedSubscription(reservationId).ifPresent(subscriptionDescriptor -> { + // find the least expensive ticket + var ticket = tickets.stream().min(Comparator.comparing(TicketPriceContainer::getFinalPriceCts)).orElseThrow(); + final int ticketPriceCts = ticket.getSummarySrcPriceCts(); + final int priceBeforeVat = SummaryPriceContainer.getSummaryPriceBeforeVatCts(singletonList(ticket)); + summary.add(new SummaryRow(subscriptionDescriptor.getLocalizedTitle(locale), + "-" + formatCents(ticketPriceCts, currencyCode), + "-" + formatCents(priceBeforeVat, currencyCode), + 1, + "-" + formatCents(ticketPriceCts, currencyCode), + "-" + formatCents(priceBeforeVat, currencyCode), + ticketPriceCts, + SummaryType.SUBSCRIPTION + )); + }); + } + + // return summary; } - private Stream>> streamAdditionalServiceItems(String reservationId, Event event) { - return additionalServiceItemRepository.findByReservationUuid(reservationId) + 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()); } String reservationUrl(String reservationId) { - return reservationUrl(reservationId, eventRepository.findByReservationId(reservationId)); + return purchaseContextManager.findByReservationId(reservationId) + .map(pc -> reservationUrl(reservationId, pc)) + .orElse(""); } - public String reservationUrl(String reservationId, Event event) { - return reservationUrl(ticketReservationRepository.findReservationById(reservationId), event); + public String reservationUrl(String reservationId, PurchaseContext purchaseContext) { + return reservationUrl(ticketReservationRepository.findReservationById(reservationId), purchaseContext); } - String baseUrl(Event event) { - return StringUtils.removeEnd(configurationManager.getFor(BASE_URL, ConfigurationLevel.event(event)).getRequiredValue(), "/"); + String baseUrl(PurchaseContext purchaseContext) { + var configurationLevel = purchaseContext.event().map(ConfigurationLevel::event) + .orElseGet(() -> ConfigurationLevel.organization(purchaseContext.getOrganizationId())); + return StringUtils.removeEnd(configurationManager.getFor(BASE_URL, configurationLevel).getRequiredValue(), "/"); } - String reservationUrl(TicketReservation reservation, Event event) { - return baseUrl(event) + "/event/" + event.getShortName() + "/reservation/" + reservation.getId() + "?lang="+reservation.getUserLanguage(); + String reservationUrl(TicketReservation reservation, PurchaseContext purchaseContext) { + return baseUrl(purchaseContext) + "/"+purchaseContext.getType()+ "/" + purchaseContext.getPublicIdentifier() + "/reservation/" + reservation.getId() + "?lang="+reservation.getUserLanguage(); } String ticketUrl(Event event, String ticketId) { @@ -1475,9 +1599,12 @@ private void cancelPendingReservation(TicketReservation reservation, boolean exp private void cancelReservation(TicketReservation reservation, boolean expired, String username) { String reservationId = reservation.getId(); - Event event = eventRepository.findByReservationId(reservationId); - cleanupReferencesToReservation(expired, username, reservationId, event); - removeReservation(event, reservation, expired, username); + purchaseContextManager.findByReservationId(reservationId).ifPresent(pc -> { + cleanupReferencesToReservation(expired, username, reservationId, pc); + removeReservation(pc, reservation, expired, username); + }); + + } private void creditReservation(TicketReservation reservation, String username) { @@ -1489,40 +1616,44 @@ private void creditReservation(TicketReservation reservation, String username) { extensionManager.handleReservationsCreditNoteIssuedForEvent(event, Collections.singletonList(reservationId)); } - private void cleanupReferencesToReservation(boolean expired, String username, String reservationId, EventAndOrganizationId event) { + private void cleanupReferencesToReservation(boolean expired, String username, String reservationId, PurchaseContext purchaseContext) { List reservationIdsToRemove = singletonList(reservationId); specialPriceRepository.resetToFreeAndCleanupForReservation(reservationIdsToRemove); groupManager.deleteWhitelistedTicketsForReservation(reservationId); ticketRepository.resetCategoryIdForUnboundedCategories(reservationIdsToRemove); ticketFieldRepository.deleteAllValuesForReservations(reservationIdsToRemove); + subscriptionRepository.deleteSubscriptionWithReservationId(List.of(reservationId)); + subscriptionRepository.decrementUse(List.of(reservationId)); int updatedAS = additionalServiceItemRepository.updateItemsStatusWithReservationUUID(reservationId, expired ? AdditionalServiceItemStatus.EXPIRED : AdditionalServiceItemStatus.CANCELLED); - int updatedTickets = ticketRepository.findTicketIdsInReservation(reservationId).stream().mapToInt( - tickedId -> ticketRepository.releaseExpiredTicket(reservationId, event.getId(), tickedId, UUID.randomUUID().toString()) - ).sum(); - Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); + purchaseContext.event().ifPresent(event -> { + int updatedTickets = ticketRepository.findTicketIdsInReservation(reservationId).stream().mapToInt( + tickedId -> ticketRepository.releaseExpiredTicket(reservationId, event.getId(), tickedId, UUID.randomUUID().toString()) + ).sum(); + Validate.isTrue(updatedTickets + updatedAS > 0, "no items have been updated"); + }); transactionRepository.deleteForReservations(List.of(reservationId)); waitingQueueManager.fireReservationExpired(reservationId); - auditingRepository.insert(reservationId, userRepository.nullSafeFindIdByUserName(username).orElse(null), event.getId(), expired ? Audit.EventType.CANCEL_RESERVATION_EXPIRED : Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, reservationId); + auditingRepository.insert(reservationId, userRepository.nullSafeFindIdByUserName(username).orElse(null), purchaseContext.event().map(Event::getId).orElse(null), expired ? Audit.EventType.CANCEL_RESERVATION_EXPIRED : Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, reservationId); } - private void removeReservation(Event event, TicketReservation reservation, boolean expired, String username) { + private void removeReservation(PurchaseContext purchaseContext, TicketReservation reservation, boolean expired, String username) { //handle removal of ticket String reservationIdToRemove = reservation.getId(); List wrappedReservationIdToRemove = Collections.singletonList(reservationIdToRemove); waitingQueueManager.cleanExpiredReservations(wrappedReservationIdToRemove); - int result = billingDocumentRepository.deleteForReservation(reservationIdToRemove, event.getId()); + int result = billingDocumentRepository.deleteForReservation(reservationIdToRemove); if(result > 0) { log.warn("deleted {} documents for reservation id {}", result, reservationIdToRemove); } // if(expired) { - extensionManager.handleReservationsExpiredForEvent(event, wrappedReservationIdToRemove); + extensionManager.handleReservationsExpiredForEvent(purchaseContext, wrappedReservationIdToRemove); } else { - extensionManager.handleReservationsCancelledForEvent(event, wrappedReservationIdToRemove); + extensionManager.handleReservationsCancelledForEvent(purchaseContext, wrappedReservationIdToRemove); } int removedReservation = ticketReservationRepository.remove(wrappedReservationIdToRemove); Validate.isTrue(removedReservation == 1, "expected exactly one removed reservation, got " + removedReservation); - auditingRepository.insert(reservationIdToRemove, userRepository.nullSafeFindIdByUserName(username).orElse(null), event.getId(), expired ? Audit.EventType.CANCEL_RESERVATION_EXPIRED : Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, reservationIdToRemove); + auditingRepository.insert(reservationIdToRemove, userRepository.nullSafeFindIdByUserName(username).orElse(null), purchaseContext.event().map(Event::getId).orElse(null), expired ? Audit.EventType.CANCEL_RESERVATION_EXPIRED : Audit.EventType.CANCEL_RESERVATION, new Date(), Audit.EntityType.RESERVATION, reservationIdToRemove); } public Optional getSpecialPriceByCode(String code) { @@ -1537,8 +1668,8 @@ public Optional findFirstInReservation(String reservationId) { return ticketRepository.findFirstTicketInReservation(reservationId); } - public Optional getVAT(EventAndOrganizationId event) { - return configurationManager.getFor(VAT_NR, ConfigurationLevel.event(event)).getValue(); + public Optional getVAT(PurchaseContext purchaseContext) { + return configurationManager.getFor(VAT_NR, purchaseContext.getConfigurationLevel()).getValue(); } public void updateTicketOwner(Ticket ticket, @@ -1578,7 +1709,7 @@ public void updateTicketOwner(Ticket ticket, if (!admin && StringUtils.isNotBlank(ticket.getEmail()) && !equalsIgnoreCase(newEmail, ticket.getEmail()) && ticket.getStatus() == TicketStatus.ACQUIRED) { Locale oldUserLocale = LocaleUtil.forLanguageTag(ticket.getUserLanguage()); - String subject = messageSourceManager.getMessageSourceForEvent(event).getMessage("ticket-has-changed-owner-subject", new Object[] {event.getDisplayName()}, oldUserLocale); + String subject = messageSourceManager.getMessageSourceFor(event).getMessage("ticket-has-changed-owner-subject", new Object[] {event.getDisplayName()}, oldUserLocale); notificationManager.sendSimpleEmail(event, ticket.getTicketsReservationId(), ticket.getEmail(), subject, () -> ownerChangeTextBuilder.generate(newTicket)); if(event.getBegin().isBefore(event.now(clockProvider))) { Organization organization = organizationRepository.getById(event.getOrganizationId()); @@ -1693,7 +1824,7 @@ public void sendReminderForOfflinePayments() { Map model = prepareModelForReservationEmail(event, reservation); Locale locale = p.getRight(); ticketReservationRepository.flagAsOfflinePaymentReminderSent(reservation.getId()); - notificationManager.sendSimpleEmail(event, reservation.getId(), reservation.getEmail(), messageSourceManager.getMessageSourceForEvent(event).getMessage("reservation.reminder.mail.subject", + 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)); }); } @@ -1739,7 +1870,7 @@ public void sendReminderForOptionalData() { private void sendOptionalDataReminder(Pair> eventAndTickets) { nestedTransactionTemplate.execute(ts -> { Event event = eventAndTickets.getLeft(); - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); int daysBeforeStart = configurationManager.getFor(ASSIGNMENT_REMINDER_START, ConfigurationLevel.event(event)).getValueAsIntOrDefault(10); List tickets = eventAndTickets.getRight().stream().filter(t -> !ticketFieldRepository.hasOptionalData(t.getId())).collect(toList()); Set notYetNotifiedReservations = tickets.stream().map(Ticket::getTicketsReservationId).distinct().filter(rid -> findByIdForNotification(rid, clockProvider.withZone(event.getZoneId()), daysBeforeStart).isPresent()).collect(toSet()); @@ -1770,7 +1901,7 @@ private void sendAssignmentReminder(Pair> p) { try { nestedTransactionTemplate.execute(ts -> { Event event = p.getLeft(); - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); ZoneId eventZoneId = event.getZoneId(); int quietPeriod = configurationManager.getFor(ASSIGNMENT_REMINDER_INTERVAL, ConfigurationLevel.event(event)).getValueAsIntOrDefault(3); p.getRight().stream() @@ -1800,11 +1931,11 @@ public TicketReservation findByPartialID(String reservationId) { return results.get(0); } - public String getShortReservationID(EventAndOrganizationId event, String reservationId) { + public String getShortReservationID(Configurable event, String reservationId) { return configurationManager.getShortReservationID(event, findById(reservationId).orElseThrow()); } - public String getShortReservationID(EventAndOrganizationId event, TicketReservation reservation) { + public String getShortReservationID(Configurable event, TicketReservation reservation) { return configurationManager.getShortReservationID(event, reservation); } @@ -1816,7 +1947,7 @@ public int countAvailableTickets(EventAndOrganizationId event, TicketCategory ca } public void releaseTicket(Event event, TicketReservation ticketReservation, final Ticket ticket) { - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); var category = ticketCategoryRepository.getByIdAndActive(ticket.getCategoryId(), event.getId()); var isFree = ticket.getFinalPriceCts() == 0; @@ -1865,8 +1996,8 @@ public void releaseTicket(Event event, TicketReservation ticketReservation, fina } } - public int getReservationTimeout(EventAndOrganizationId event) { - return configurationManager.getFor(RESERVATION_TIMEOUT, ConfigurationLevel.event(event)).getValueAsIntOrDefault(25); + int getReservationTimeout(Configurable configurable) { + return configurationManager.getFor(RESERVATION_TIMEOUT, configurable.getConfigurationLevel()).getValueAsIntOrDefault(25); } public void validateAndConfirmOfflinePayment(String reservationId, Event event, BigDecimal paidAmount, String username) { @@ -1978,8 +2109,8 @@ static String buildCompleteBillingAddress(CustomerName customerName, } - public void updateReservationInvoicingAdditionalInformation(String reservationId, EventAndOrganizationId event, TicketReservationInvoicingAdditionalInfo ticketReservationInvoicingAdditionalInfo) { - auditingRepository.insert(reservationId, null, event.getId(), BILLING_DATA_UPDATED, new Date(), RESERVATION, reservationId, json.asJsonString(List.of(ticketReservationInvoicingAdditionalInfo))); + public void updateReservationInvoicingAdditionalInformation(String reservationId, PurchaseContext purchaseContext, TicketReservationInvoicingAdditionalInfo ticketReservationInvoicingAdditionalInfo) { + auditingRepository.insert(reservationId, null, purchaseContext.event().map(Event::getId).orElse(null), BILLING_DATA_UPDATED, new Date(), RESERVATION, reservationId, json.asJsonString(List.of(ticketReservationInvoicingAdditionalInfo))); ticketReservationRepository.updateInvoicingAdditionalInformation(reservationId, json.asJsonString(ticketReservationInvoicingAdditionalInfo)); } @@ -2026,20 +2157,19 @@ public PaymentWebhookResult processTransactionWebhook(String body, String signat } - //FIXME in some cases the reload is redundant //reload the payment provider, this time within a more sensible context - var paymentContextReloaded = new PaymentContext(eventRepository.findByReservationId(reservation.getId())); + var purchaseContext = purchaseContextManager.findByReservationId(reservation.getId()).orElseThrow(); + var paymentContextReloaded = new PaymentContext(purchaseContext); return paymentManager.lookupProviderByTransactionAndCapabilities(transaction, List.of(WebhookHandler.class)) .map(provider -> { var paymentWebhookResult = ((WebhookHandler)provider).processWebhook(transactionPayload, transaction, paymentContextReloaded); - var event = eventRepository.findByReservationId(reservation.getId()); String operationType = transactionPayload.getType(); - return handlePaymentWebhookResult(event, paymentProvider, paymentWebhookResult, reservation, transaction, paymentContextReloaded, operationType, true); + return handlePaymentWebhookResult(purchaseContext, paymentProvider, paymentWebhookResult, reservation, transaction, paymentContextReloaded, operationType, true); }) .orElseGet(() -> PaymentWebhookResult.error("payment provider not found")); } - private PaymentWebhookResult handlePaymentWebhookResult(Event event, + private PaymentWebhookResult handlePaymentWebhookResult(PurchaseContext purchaseContext, PaymentProvider paymentProvider, PaymentWebhookResult paymentWebhookResult, TicketReservation reservation, @@ -2067,8 +2197,8 @@ private PaymentWebhookResult handlePaymentWebhookResult(Event event, log.trace("Event {} for reservation {} has been successfully processed.", operationType, reservation.getId()); var totalPrice = totalReservationCostWithVAT(reservation).getLeft(); var paymentToken = paymentWebhookResult.getPaymentToken(); - var paymentSpecification = new PaymentSpecification(reservation, totalPrice, event, paymentToken, - orderSummaryForReservation(reservation, event), true, eventHasPrivacyPolicy(event)); + var paymentSpecification = new PaymentSpecification(reservation, totalPrice, purchaseContext, paymentToken, + orderSummaryForReservation(reservation, purchaseContext), true, eventHasPrivacyPolicy(purchaseContext)); transitionToComplete(paymentSpecification, totalPrice, paymentToken.getPaymentProvider(), null); break; } @@ -2091,14 +2221,14 @@ private PaymentWebhookResult handlePaymentWebhookResult(Event event, int slackTime = configurationManager.getFor(RESERVATION_MIN_TIMEOUT_AFTER_FAILED_PAYMENT, paymentContext.getConfigurationLevel()).getValueAsIntOrDefault(10); PaymentMethod paymentMethodForTransaction = paymentProvider.getPaymentMethodForTransaction(transaction); if(expiration.before(now)) { - sendTransactionFailedEmail(event, reservation, paymentMethodForTransaction, paymentWebhookResult, true); + sendTransactionFailedEmail(purchaseContext, reservation, paymentMethodForTransaction, paymentWebhookResult, true); cancelReservation(reservation, false, null); break; } else if(DateUtils.addMinutes(expiration, -slackTime).before(now)) { ticketReservationRepository.updateValidity(reservation.getId(), DateUtils.addMinutes(now, slackTime)); } reTransitionToPending(reservation.getId(), false); - sendTransactionFailedEmail(event, reservation, paymentMethodForTransaction, paymentWebhookResult, false); + purchaseContext.event().ifPresent(event -> sendTransactionFailedEmail(event, reservation, paymentMethodForTransaction, paymentWebhookResult, false)); break; } case CANCELLED: { @@ -2113,17 +2243,17 @@ private PaymentWebhookResult handlePaymentWebhookResult(Event event, return paymentWebhookResult; } - public Optional forceTransactionCheck(Event event, TicketReservation reservation) { + public Optional forceTransactionCheck(PurchaseContext purchaseContext, TicketReservation reservation) { var optionalTransaction = transactionRepository.loadOptionalByReservationIdAndStatusForUpdate(reservation.getId(), Transaction.Status.PENDING); if(optionalTransaction.isEmpty()) { return Optional.empty(); } var transaction = optionalTransaction.get(); - PaymentContext paymentContext = new PaymentContext(event, reservation.getId()); - return checkTransactionStatus(event, reservation) + PaymentContext paymentContext = new PaymentContext(purchaseContext, reservation.getId()); + return checkTransactionStatus(purchaseContext, reservation) .map(providerAndWebhookResult -> { var paymentWebhookResult = providerAndWebhookResult.getRight(); - handlePaymentWebhookResult(event, providerAndWebhookResult.getLeft(), paymentWebhookResult, reservation, transaction, paymentContext, "force-check", true); + handlePaymentWebhookResult(purchaseContext, providerAndWebhookResult.getLeft(), paymentWebhookResult, reservation, transaction, paymentContext, "force-check", true); switch(paymentWebhookResult.getType()) { case FAILED: @@ -2144,13 +2274,13 @@ public Optional forceTransactionCheck(Event event, TicketReservat }); } - private Optional> checkTransactionStatus(Event event, TicketReservation reservation) { + private Optional> checkTransactionStatus(PurchaseContext purchaseContext, TicketReservation reservation) { var optionalTransaction = transactionRepository.loadOptionalByReservationIdAndStatusForUpdate(reservation.getId(), Transaction.Status.PENDING); if(optionalTransaction.isEmpty()) { return Optional.empty(); } var transaction = optionalTransaction.get(); - PaymentContext paymentContext = new PaymentContext(event, reservation.getId()); + PaymentContext paymentContext = new PaymentContext(purchaseContext, reservation.getId()); return paymentManager.lookupProviderByTransactionAndCapabilities(transaction, List.of(WebhookHandler.class)) .map(provider -> Pair.of(provider, ((WebhookHandler)provider).forceTransactionCheck(reservation, transaction, paymentContext))); } @@ -2160,55 +2290,55 @@ private boolean reservationStatusNotCompatible(TicketReservation reservation) { return status != EXTERNAL_PROCESSING_PAYMENT && status != WAITING_EXTERNAL_CONFIRMATION; } - private void sendTransactionFailedEmail(Event event, TicketReservation reservation, PaymentMethod paymentMethod, PaymentWebhookResult paymentWebhookResult, boolean cancelReservation) { - var shortReservationID = getShortReservationID(event, reservation); - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + private void sendTransactionFailedEmail(PurchaseContext purchaseContext, TicketReservation reservation, PaymentMethod paymentMethod, PaymentWebhookResult paymentWebhookResult, boolean cancelReservation) { + var shortReservationID = getShortReservationID(purchaseContext, reservation); + var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); Map model = Map.of( - "organization", organizationRepository.getById(event.getOrganizationId()), + "organization", organizationRepository.getById(purchaseContext.getOrganizationId()), "reservationCancelled", cancelReservation, "reservation", reservation, "reservationId", shortReservationID, - "eventName", event.getDisplayName(), + "eventName", purchaseContext.getDisplayName(), "provider", Objects.requireNonNullElse(paymentMethod.name(), ""), "reason", paymentWebhookResult.getReason(), - "reservationUrl", reservationUrl(reservation, event)); + "reservationUrl", reservationUrl(reservation, purchaseContext)); Locale locale = LocaleUtil.forLanguageTag(reservation.getUserLanguage()); - if(cancelReservation || configurationManager.getFor(NOTIFY_ALL_FAILED_PAYMENT_ATTEMPTS, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault()) { - notificationManager.sendSimpleEmail(event, reservation.getId(), reservation.getEmail(), messageSource.getMessage("email-transaction-failed.subject", - new Object[]{shortReservationID, event.getDisplayName()}, locale), - () -> templateManager.renderTemplate(event, TemplateResource.CHARGE_ATTEMPT_FAILED_EMAIL_FOR_ORGANIZER, model, locale), + if(cancelReservation || configurationManager.getFor(NOTIFY_ALL_FAILED_PAYMENT_ATTEMPTS, purchaseContext.getConfigurationLevel()).getValueAsBooleanOrDefault()) { + notificationManager.sendSimpleEmail(purchaseContext, reservation.getId(), reservation.getEmail(), messageSource.getMessage("email-transaction-failed.subject", + new Object[]{shortReservationID, purchaseContext.getDisplayName()}, locale), + () -> templateManager.renderTemplate(purchaseContext, TemplateResource.CHARGE_ATTEMPT_FAILED_EMAIL_FOR_ORGANIZER, model, locale), List.of()); } - notificationManager.sendSimpleEmail(event, reservation.getId(), reservation.getEmail(), messageSource.getMessage("email-transaction-failed.subject", - new Object[]{shortReservationID, event.getDisplayName()}, locale), - () -> templateManager.renderTemplate(event, TemplateResource.CHARGE_ATTEMPT_FAILED_EMAIL, model, locale), + notificationManager.sendSimpleEmail(purchaseContext, reservation.getId(), reservation.getEmail(), messageSource.getMessage("email-transaction-failed.subject", + new Object[]{shortReservationID, purchaseContext.getDisplayName()}, locale), + () -> templateManager.renderTemplate(purchaseContext, TemplateResource.CHARGE_ATTEMPT_FAILED_EMAIL, model, locale), List.of()); } - public Optional initTransaction(Event event, String reservationId, PaymentMethod paymentMethod, Map> params) { + public Optional initTransaction(PurchaseContext purchaseContext, String reservationId, PaymentMethod paymentMethod, Map> params) { ticketReservationRepository.lockReservationForUpdate(reservationId); var reservation = ticketReservationRepository.findReservationById(reservationId); var transactionRequest = new TransactionRequest(totalReservationCostWithVAT(reservation).getLeft(), ticketReservationRepository.getBillingDetailsForReservation(reservationId)); - var optionalProvider = paymentManager.lookupProviderByMethodAndCapabilities(paymentMethod, new PaymentContext(event), transactionRequest, List.of(WebhookHandler.class, ServerInitiatedTransaction.class)); + var optionalProvider = paymentManager.lookupProviderByMethodAndCapabilities(paymentMethod, new PaymentContext(purchaseContext), transactionRequest, List.of(WebhookHandler.class, ServerInitiatedTransaction.class)); if (optionalProvider.isEmpty()) { return Optional.empty(); } - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(purchaseContext); var provider = (ServerInitiatedTransaction) optionalProvider.get(); var paymentSpecification = new PaymentSpecification(reservation, - totalReservationCostWithVAT(reservation).getLeft(), event, null, - orderSummaryForReservation(reservation, event), false, false); - if(!acquireGroupMembers(reservationId, event)) { + totalReservationCostWithVAT(reservation).getLeft(), purchaseContext, null, + orderSummaryForReservation(reservation, purchaseContext), false, false); + if(!acquireGroupMembers(reservationId, purchaseContext)) { groupManager.deleteWhitelistedTicketsForReservation(reservationId); var errorMessage = messageSource.getMessage("error.STEP2_WHITELIST", null, LocaleUtil.forLanguageTag(reservation.getUserLanguage())); return Optional.of(provider.errorToken(errorMessage, false)); } var transactionToken = provider.initTransaction(paymentSpecification, params); if(transitionToExternalProcessingPayment(reservation)) { - auditingRepository.insert(reservationId, null, event.getId(), INIT_PAYMENT, new Date(), RESERVATION, reservationId); + auditingRepository.insert(reservationId, null, purchaseContext, INIT_PAYMENT, new Date(), RESERVATION, reservationId); } return Optional.of(transactionToken); } @@ -2233,6 +2363,18 @@ public void checkOfflinePaymentsStatus() { .forEach(this::checkOfflinePaymentsForEvent); } + public Optional createSubscriptionReservation(SubscriptionDescriptor subscriptionDescriptor, Locale locale) { + Date expiration = DateUtils.addMinutes(new Date(), getReservationTimeout(subscriptionDescriptor)); + try { + return Optional.of(createSubscriptionReservation(subscriptionDescriptor, expiration, locale)); + } catch (CannotProceedWithPayment cannotProceedWithPayment) { + log.error("missing payment methods", cannotProceedWithPayment); + } catch (NotEnoughTicketsException nex) { + log.error("cannot acquire subscription", nex); + } + return Optional.empty(); + } + public Optional createTicketReservation(Event event, List list, List additionalServices, @@ -2261,14 +2403,14 @@ public Optional createTicketReservation(Event event, return Optional.empty(); } - boolean canProceedWithPayment(Event event, TotalPrice totalPrice, String reservationId) { + boolean canProceedWithPayment(PurchaseContext purchaseContext, TotalPrice totalPrice, String reservationId) { if(!totalPrice.requiresPayment()) { return true; } var categoriesInReservation = ticketRepository.getCategoriesIdToPayInReservation(reservationId); - var blacklistedPaymentMethods = configurationManager.getBlacklistedMethodsForReservation(event, categoriesInReservation); + var blacklistedPaymentMethods = configurationManager.getBlacklistedMethodsForReservation(purchaseContext, categoriesInReservation); var transactionRequest = new TransactionRequest(totalPrice, ticketReservationRepository.getBillingDetailsForReservation(reservationId)); - var availableMethods = paymentManager.getPaymentMethods(event, transactionRequest).stream().filter(pm -> pm.getStatus() == PaymentMethodStatus.ACTIVE && pm.getPaymentMethod() != PaymentMethod.NONE).collect(toList()); + var availableMethods = paymentManager.getPaymentMethods(purchaseContext, transactionRequest).stream().filter(pm -> pm.getStatus() == PaymentMethodStatus.ACTIVE && pm.getPaymentMethod() != PaymentMethod.NONE).collect(toList()); if(availableMethods.size() == 0 || availableMethods.stream().allMatch(pm -> blacklistedPaymentMethods.contains(pm.getPaymentMethod()))) { log.error("Cannot proceed with reservation. No payment methods available {} or all blacklisted {}", availableMethods, blacklistedPaymentMethods); return false; @@ -2381,4 +2523,40 @@ private boolean automaticConfirmOfflinePayment(Event event, String reservationId return false; } } + + public boolean applySubscriptionCode(TicketReservation reservation, UUID subscriptionId, int amount) { + + if (ticketReservationRepository.hasSubscriptionApplied(reservation.getId())) { + return false; + } + Subscription subscription = subscriptionRepository.findSubscriptionByIdForUpdate(subscriptionId); + var subscriptionDescriptor = subscriptionRepository.findOne(subscription.getSubscriptionDescriptorId()).orElseThrow(); + + if (!subscription.isValid(subscriptionDescriptor)) { + return false; + } + //TODO check if it can be applied more than once for a given event + + ticketReservationRepository.applySubscription(reservation.getId(), subscription.getId()); + subscriptionRepository.increaseUse(subscription.getId()); + // + var totalPrice = totalReservationCostWithVAT(reservation.getId()).getLeft(); + + var purchaseContext = purchaseContextManager.findByReservationId(reservation.getId()).orElseThrow(); + ticketReservationRepository.updateBillingData(purchaseContext.getVatStatus(), calculateSrcPrice(purchaseContext.getVatStatus(), totalPrice), totalPrice.getPriceWithVAT(), totalPrice.getVAT(), Math.abs(totalPrice.getDiscount()), purchaseContext.getCurrency(), null + , reservation.getVatCountryCode(), reservation.isInvoiceRequested(), reservation.getId()); + + return true; + } + + public boolean removeSubscription(TicketReservation reservation) { + var reservationId = reservation.getId(); + if (ticketReservationRepository.hasSubscriptionApplied(reservationId)) { + subscriptionRepository.decrementUse(List.of(reservationId)); + ticketReservationRepository.applySubscription(reservationId, null); + return true; + } else { + return false; + } + } } diff --git a/src/main/java/alfio/manager/UploadedResourceManager.java b/src/main/java/alfio/manager/UploadedResourceManager.java index 7aeb095376..c31b936979 100644 --- a/src/main/java/alfio/manager/UploadedResourceManager.java +++ b/src/main/java/alfio/manager/UploadedResourceManager.java @@ -27,7 +27,10 @@ import javax.imageio.ImageIO; import java.awt.image.BufferedImage; -import java.io.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -150,9 +153,9 @@ private static Map getAttributes(UploadBase64FileModification fi } } - public Optional findCascading(int organizationId, int eventId, String savedName) { + public Optional findCascading(int organizationId, Integer eventId, String savedName) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); - if(hasResource(organizationId, eventId, savedName)) { + if(eventId != null && hasResource(organizationId, eventId, savedName)) { outputResource(organizationId, eventId, savedName, baos); return Optional.of(baos.toByteArray()); } else if (hasResource(organizationId, savedName)) { diff --git a/src/main/java/alfio/manager/WaitingQueueManager.java b/src/main/java/alfio/manager/WaitingQueueManager.java index 84a490ab72..7786d75811 100644 --- a/src/main/java/alfio/manager/WaitingQueueManager.java +++ b/src/main/java/alfio/manager/WaitingQueueManager.java @@ -91,7 +91,7 @@ private void validateSelectedCategoryId(int eventId, Integer selectedCategoryId) } private void notifySubscription(Event event, CustomerName name, String email, Locale userLanguage, WaitingQueueSubscription.Type subscriptionType) { - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); Organization organization = organizationRepository.getById(event.getOrganizationId()); Map model = TemplateResource.buildModelForWaitingQueueJoined(organization, event, name); notificationManager.sendSimpleEmail(event, null, email, messageSource.getMessage("email-waiting-queue.subscribed.subject", new Object[]{event.getDisplayName()}, userLanguage), diff --git a/src/main/java/alfio/manager/WaitingQueueSubscriptionProcessor.java b/src/main/java/alfio/manager/WaitingQueueSubscriptionProcessor.java index 66e3290508..c4aad888d7 100644 --- a/src/main/java/alfio/manager/WaitingQueueSubscriptionProcessor.java +++ b/src/main/java/alfio/manager/WaitingQueueSubscriptionProcessor.java @@ -17,7 +17,6 @@ package alfio.manager; import alfio.manager.i18n.MessageSourceManager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.Event; import alfio.model.EventAndOrganizationId; @@ -109,12 +108,12 @@ public void revertTicketToFreeIfCategoryIsExpired(Event event) { } private boolean isWaitingListFormEnabled(EventAndOrganizationId event) { - var res = configurationManager.getFor(Set.of(ENABLE_WAITING_QUEUE, ENABLE_PRE_REGISTRATION), ConfigurationLevel.event(event)); + var res = configurationManager.getFor(Set.of(ENABLE_WAITING_QUEUE, ENABLE_PRE_REGISTRATION), event.getConfigurationLevel()); return res.get(ENABLE_WAITING_QUEUE).getValueAsBooleanOrDefault() || res.get(ENABLE_PRE_REGISTRATION).getValueAsBooleanOrDefault(); } public void distributeAvailableSeats(Event event) { - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); waitingQueueManager.distributeSeats(event).forEach(triple -> { WaitingQueueSubscription subscription = triple.getLeft(); Locale locale = subscription.getLocale(); diff --git a/src/main/java/alfio/manager/i18n/MessageSourceManager.java b/src/main/java/alfio/manager/i18n/MessageSourceManager.java index e092468a53..549dc5d4a5 100644 --- a/src/main/java/alfio/manager/i18n/MessageSourceManager.java +++ b/src/main/java/alfio/manager/i18n/MessageSourceManager.java @@ -16,7 +16,8 @@ */ package alfio.manager.i18n; -import alfio.model.EventAndOrganizationId; +import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.repository.system.ConfigurationRepository; import alfio.util.CustomResourceBundleMessageSource; import alfio.util.LocaleUtil; @@ -51,13 +52,20 @@ public Set getKeys(String basename, Locale locale) { return messageSource.getKeys(basename, locale); } - public Pair>> getMessageSourceForEventAndOverride(EventAndOrganizationId eventAndOrganizationId) { - var override = configurationRepository.getEventOverrideMessages(eventAndOrganizationId.getOrganizationId(), eventAndOrganizationId.getId()); + public Pair>> getMessageSourceForPurchaseContextAndOverride(PurchaseContext purchaseContext) { + Map> override = purchaseContext.event() + .map(event -> configurationRepository.getEventOverrideMessages(event.getOrganizationId(), event.getId())) + .orElseGet(() -> configurationRepository.getOrganizationOverrideMessages(purchaseContext.getOrganizationId())); return Pair.of(new MessageSourceWithOverride(messageSource, override), override); } - public MessageSource getMessageSourceForEvent(EventAndOrganizationId eventAndOrganizationId) { - return getMessageSourceForEventAndOverride(eventAndOrganizationId).getLeft(); + public MessageSource getMessageSourceFor(PurchaseContext purchaseContext) { + return getMessageSourceForPurchaseContextAndOverride(purchaseContext).getLeft(); + } + + public MessageSource getMessageSourceFor(int orgId, int eventId) { + var override = configurationRepository.getEventOverrideMessages(orgId, eventId); + return new MessageSourceWithOverride(messageSource, override); } public MessageSource getRootMessageSource() { diff --git a/src/main/java/alfio/manager/payment/BankTransferManager.java b/src/main/java/alfio/manager/payment/BankTransferManager.java index 288ac6e033..549a5e4a3d 100644 --- a/src/main/java/alfio/manager/payment/BankTransferManager.java +++ b/src/main/java/alfio/manager/payment/BankTransferManager.java @@ -17,9 +17,8 @@ package alfio.manager.payment; import alfio.manager.support.PaymentResult; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; -import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.TicketReservation; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; @@ -79,7 +78,7 @@ boolean bankTransferEnabledForMethod(PaymentMethod paymentMethod, PaymentContext boolean bankTransferActive(PaymentContext paymentContext, Map options) { return options.get(BANK_TRANSFER_ENABLED).getValueAsBooleanOrDefault() - && (paymentContext.getEvent() == null || getOfflinePaymentWaitingPeriod(paymentContext.getEvent(), options.get(OFFLINE_PAYMENT_DAYS).getValueAsIntOrDefault(5)).orElse(0) > 0); + && (paymentContext.getPurchaseContext() == null || getOfflinePaymentWaitingPeriod(paymentContext.getPurchaseContext(), options.get(OFFLINE_PAYMENT_DAYS).getValueAsIntOrDefault(5)).orElse(0) > 0); } Map options(PaymentContext paymentContext) { @@ -107,13 +106,13 @@ void overrideExistingTransactions(PaymentSpecification spec) { @Override public Map getModelOptions(PaymentContext context) { OptionalInt delay = getOfflinePaymentWaitingPeriod(context, configurationManager); - Event event = context.getEvent(); + PurchaseContext purchaseContext = context.getPurchaseContext(); if(delay.isEmpty()) { - log.error("Already started event {} has been found with OFFLINE payment enabled" , event.getDisplayName()); + log.error("Already started event {} has been found with OFFLINE payment enabled" , purchaseContext.getDisplayName()); } Map model = new HashMap<>(); model.put("delayForOfflinePayment", Math.max(1, delay.orElse( 0 ))); - boolean recaptchaEnabled = configurationManager.isRecaptchaForOfflinePaymentAndFreeEnabled(ConfigurationLevel.event(event)); + boolean recaptchaEnabled = configurationManager.isRecaptchaForOfflinePaymentAndFreeEnabled(purchaseContext.getConfigurationLevel()); model.put("captchaRequestedForOffline", recaptchaEnabled); if(recaptchaEnabled) { model.put("recaptchaApiKey", configurationManager.getForSystem(RECAPTCHA_API_KEY).getValue().orElse(null)); @@ -133,8 +132,8 @@ void postponePayment(PaymentSpecification spec, TicketReservation.TicketReservat } public static ZonedDateTime getOfflinePaymentDeadline(PaymentContext context, ConfigurationManager configurationManager) { - Event event = context.getEvent(); - ZonedDateTime now = event.now(ClockProvider.clock()); + PurchaseContext purchaseContext = context.getPurchaseContext(); + ZonedDateTime now = purchaseContext.now(ClockProvider.clock()); int waitingPeriod = getOfflinePaymentWaitingPeriod(context, configurationManager).orElse( 0 ); if(waitingPeriod == 0) { log.warn("accepting offline payments the same day is a very bad practice and should be avoided. Please set cash payment as payment method next time"); @@ -146,13 +145,13 @@ public static ZonedDateTime getOfflinePaymentDeadline(PaymentContext context, Co } public static OptionalInt getOfflinePaymentWaitingPeriod(PaymentContext paymentContext, ConfigurationManager configurationManager) { - Event event = paymentContext.getEvent(); - return getOfflinePaymentWaitingPeriod(event, configurationManager.getFor(OFFLINE_PAYMENT_DAYS, ConfigurationLevel.event(event)).getValueAsIntOrDefault(5)); + PurchaseContext purchaseContext = paymentContext.getPurchaseContext(); + return getOfflinePaymentWaitingPeriod(purchaseContext, configurationManager.getFor(OFFLINE_PAYMENT_DAYS, purchaseContext.getConfigurationLevel()).getValueAsIntOrDefault(5)); } - private static OptionalInt getOfflinePaymentWaitingPeriod(Event event, int configuredValue) { - ZonedDateTime now = event.now(ClockProvider.clock()); - ZonedDateTime eventBegin = event.getBegin(); + private static OptionalInt getOfflinePaymentWaitingPeriod(PurchaseContext purchaseContext, int configuredValue) { + ZonedDateTime now = purchaseContext.now(ClockProvider.clock()); + ZonedDateTime eventBegin = purchaseContext.getBegin(); int daysToBegin = (int) ChronoUnit.DAYS.between(now.toLocalDate(), eventBegin.toLocalDate()); if (daysToBegin < 0) { return OptionalInt.empty(); diff --git a/src/main/java/alfio/manager/payment/BaseStripeManager.java b/src/main/java/alfio/manager/payment/BaseStripeManager.java index 7150121c99..95161a148e 100644 --- a/src/main/java/alfio/manager/payment/BaseStripeManager.java +++ b/src/main/java/alfio/manager/payment/BaseStripeManager.java @@ -18,12 +18,12 @@ import alfio.manager.support.FeeCalculator; import alfio.manager.support.PaymentResult; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.user.UserManager; -import alfio.model.Event; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; +import alfio.model.PurchaseContext.PurchaseContextType; import alfio.model.system.ConfigurationKeys; import alfio.model.system.ConfigurationPathLevel; import alfio.model.transaction.PaymentContext; @@ -76,8 +76,8 @@ class BaseStripeManager { Stripe.setAppInfo("Alf.io", "2.x", "https://alf.io"); } - String getSecretKey(EventAndOrganizationId event) { - return configurationManager.getFor(STRIPE_SECRET_KEY, ConfigurationLevel.event(event)).getRequiredValue(); + String getSecretKey(Configurable configurable) { + return configurationManager.getFor(STRIPE_SECRET_KEY, configurable.getConfigurationLevel()).getRequiredValue(); } String getWebhookSignatureKey() { @@ -150,23 +150,28 @@ Optional chargeCreditCard(PaymentSpecification spec) throws StripeExcept } protected Map createParams(PaymentSpecification spec, Map baseMetadata) { - int tickets = ticketRepository.countTicketsInReservation(spec.getReservationId()); + final int items; + if(spec.getPurchaseContext().getType() == PurchaseContextType.event) { + items = ticketRepository.countTicketsInReservation(spec.getReservationId()); + } else { + items = 1; + } Map chargeParams = new HashMap<>(); chargeParams.put("amount", spec.getPriceWithVAT()); - FeeCalculator.getCalculator(spec.getEvent(), configurationManager, spec.getCurrencyCode()) - .apply(tickets, (long) spec.getPriceWithVAT()) + var purchaseContext = spec.getPurchaseContext(); + FeeCalculator.getCalculator(purchaseContext, configurationManager, spec.getCurrencyCode()) + .apply(items, (long) spec.getPriceWithVAT()) .filter(l -> l > 0) .ifPresent(fee -> chargeParams.put("application_fee_amount", fee)); - chargeParams.put("currency", spec.getEvent().getCurrency()); - - chargeParams.put("description", String.format("%d ticket(s) for event %s", tickets, spec.getEvent().getDisplayName())); - + chargeParams.put("currency", purchaseContext.getCurrency()); + var description = purchaseContext.getType() == PurchaseContextType.event ? "ticket(s) for event" : "x subscription"; + chargeParams.put("description", String.format("%d %s %s", items, description, purchaseContext.getDisplayName())); chargeParams.put("metadata", MetadataBuilder.buildMetadata(spec, baseMetadata)); return chargeParams; } protected Optional charge(PaymentSpecification spec, Map chargeParams ) throws StripeException { - Optional opt = options(spec.getEvent(), builder -> builder.setIdempotencyKey(spec.getReservationId())); + Optional opt = options(spec.getPurchaseContext(), builder -> builder.setIdempotencyKey(spec.getReservationId())); if(opt.isEmpty()) { return Optional.empty(); } @@ -193,21 +198,21 @@ private BalanceTransaction retrieveBalanceTransaction(String balanceTransaction, return BalanceTransaction.retrieve(balanceTransaction, options); } - Optional options(Event event) { - return options(event, UnaryOperator.identity()); + Optional options(PurchaseContext purchaseContext) { + return options(purchaseContext, UnaryOperator.identity()); } - Optional options(Event event, UnaryOperator optionsBuilderConfigurer) { + Optional options(PurchaseContext purchaseContext, UnaryOperator optionsBuilderConfigurer) { RequestOptions.RequestOptionsBuilder builder = optionsBuilderConfigurer.apply(RequestOptions.builder()); - if(isConnectEnabled(new PaymentContext(event))) { - return configurationManager.getFor(STRIPE_CONNECTED_ID, ConfigurationLevel.event(event)).getValue() + if(isConnectEnabled(new PaymentContext(purchaseContext))) { + return configurationManager.getFor(STRIPE_CONNECTED_ID, purchaseContext.getConfigurationLevel()).getValue() .map(connectedId -> { //connected stripe account builder.setStripeAccount(connectedId); return builder.setApiKey(getSystemSecretKey()).build(); }); } - return Optional.of(builder.setApiKey(getSecretKey(event)).build()); + return Optional.of(builder.setApiKey(getSecretKey(purchaseContext)).build()); } Optional getConnectedAccount(PaymentContext paymentContext) { @@ -217,9 +222,9 @@ Optional getConnectedAccount(PaymentContext paymentContext) { return Optional.empty(); } - Optional getInfo(Transaction transaction, Event event) { + Optional getInfo(Transaction transaction, PurchaseContext purchaseContext) { try { - Optional requestOptionsOptional = options(event); + Optional requestOptionsOptional = options(purchaseContext); if(requestOptionsOptional.isPresent()) { RequestOptions options = requestOptionsOptional.get(); Charge charge = Charge.retrieve(transaction.getTransactionId(), options); @@ -244,7 +249,7 @@ static String getFeeAmount(List fees, String feeType) { } // https://stripe.com/docs/api#create_refund - boolean refund(Transaction transaction, Event event, Integer amountToRefund) { + boolean refund(Transaction transaction, PurchaseContext purchaseContext, Integer amountToRefund) { Optional amount = Optional.ofNullable(amountToRefund); String chargeId = transaction.getTransactionId(); try { @@ -253,11 +258,11 @@ boolean refund(Transaction transaction, Event event, Integer amountToRefund) { Map params = new HashMap<>(); params.put("charge", chargeId); amount.ifPresent(a -> params.put("amount", a)); - if(transaction.getPlatformFee() > 0 && isConnectEnabled(new PaymentContext(event))) { + if(transaction.getPlatformFee() > 0 && isConnectEnabled(new PaymentContext(purchaseContext))) { params.put("refund_application_fee", true); } - Optional requestOptionsOptional = options(event); + Optional requestOptionsOptional = options(purchaseContext); if(requestOptionsOptional.isPresent()) { RequestOptions options = requestOptionsOptional.get(); Refund r = Refund.create(params, options); diff --git a/src/main/java/alfio/manager/payment/DeferredBankTransferManager.java b/src/main/java/alfio/manager/payment/DeferredBankTransferManager.java index 1b41a4700f..9d6fdcd80c 100644 --- a/src/main/java/alfio/manager/payment/DeferredBankTransferManager.java +++ b/src/main/java/alfio/manager/payment/DeferredBankTransferManager.java @@ -55,14 +55,14 @@ public PaymentProxy getPaymentProxy() { @Override public boolean accept(PaymentMethod paymentMethod, PaymentContext paymentContext, TransactionRequest transactionRequest) { var options = bankTransferManager.options(paymentContext); - return paymentContext.getEvent() != null + return paymentContext.getPurchaseContext() != null && bankTransferManager.bankTransferEnabledForMethod(paymentMethod, paymentContext, options) && bankTransferManager.isPaymentDeferredEnabled(options); } @Override public PaymentResult doPayment(PaymentSpecification spec) { - bankTransferManager.postponePayment(spec, DEFERRED_OFFLINE_PAYMENT, Objects.requireNonNull(spec.getEvent()).getBegin()); + bankTransferManager.postponePayment(spec, DEFERRED_OFFLINE_PAYMENT, Objects.requireNonNull(spec.getPurchaseContext()).getBegin()); bankTransferManager.overrideExistingTransactions(spec); return PaymentResult.successful(NOT_YET_PAID_TRANSACTION_ID); } diff --git a/src/main/java/alfio/manager/payment/MollieWebhookPaymentManager.java b/src/main/java/alfio/manager/payment/MollieWebhookPaymentManager.java index e4b04e1db1..7557292803 100644 --- a/src/main/java/alfio/manager/payment/MollieWebhookPaymentManager.java +++ b/src/main/java/alfio/manager/payment/MollieWebhookPaymentManager.java @@ -16,14 +16,15 @@ */ package alfio.manager.payment; +import alfio.manager.PurchaseContextManager; import alfio.manager.support.FeeCalculator; import alfio.manager.support.PaymentResult; import alfio.manager.support.PaymentWebhookResult; import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.system.ConfigurationManager.MaybeConfiguration; -import alfio.model.Event; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; import alfio.model.TicketReservation; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; @@ -32,7 +33,6 @@ import alfio.model.transaction.capabilities.WebhookHandler; import alfio.model.transaction.token.MollieToken; import alfio.model.transaction.webhook.MollieWebhookPayload; -import alfio.repository.EventRepository; import alfio.repository.TicketRepository; import alfio.repository.TicketReservationRepository; import alfio.repository.TransactionRepository; @@ -73,7 +73,7 @@ @AllArgsConstructor public class MollieWebhookPaymentManager implements PaymentProvider, WebhookHandler, RefundRequest, PaymentInfo { - public static final String WEBHOOK_URL_TEMPLATE = "/api/payment/webhook/mollie/event/{eventShortName}/reservation/{reservationId}"; + public static final String WEBHOOK_URL_TEMPLATE = "/api/payment/webhook/mollie/reservation/{reservationId}"; private static final Set EMPTY_METHODS = Collections.unmodifiableSet(EnumSet.noneOf(PaymentMethod.class)); static final Map SUPPORTED_METHODS = Map.of( "ideal", PaymentMethod.IDEAL, @@ -112,11 +112,11 @@ public class MollieWebhookPaymentManager implements PaymentProvider, WebhookHand private final HttpClient client; private final ConfigurationManager configurationManager; private final TicketReservationRepository ticketReservationRepository; - private final EventRepository eventRepository; private final TicketRepository ticketRepository; private final TransactionRepository transactionRepository; private final MollieConnectManager mollieConnectManager; private final ClockProvider clockProvider; + private final PurchaseContextManager purchaseContextManager; private HttpRequest.Builder requestFor(String url, Map configuration, ConfigurationLevel configurationLevel) { // check if platform mode is active @@ -247,8 +247,8 @@ public PaymentResult doPayment(PaymentSpecification spec) { private PaymentResult getPaymentResult(PaymentSpecification spec) { try { - var event = spec.getEvent(); - var configuration = getConfiguration(ConfigurationLevel.event(event)); + var purchaseContext = spec.getPurchaseContext(); + var configuration = getConfiguration(purchaseContext.getConfigurationLevel()); var reservationId = spec.getReservationId(); var reservation = ticketReservationRepository.findReservationById(reservationId); String baseUrl = StringUtils.removeEnd(configuration.get(BASE_URL).getRequiredValue(), "/"); @@ -272,7 +272,7 @@ private PaymentResult tryToReuseExistingTransaction(Transaction transaction, PaymentSpecification spec, String baseUrl, Map configuration) throws IOException, InterruptedException { - var getPaymentResponse = callGetPayment(transaction.getPaymentId(), configuration, ConfigurationLevel.event(spec.getEvent())); + var getPaymentResponse = callGetPayment(transaction.getPaymentId(), configuration, spec.getPurchaseContext().getConfigurationLevel()); if(HttpUtils.callSuccessful(getPaymentResponse)) { try (var responseReader = new InputStreamReader(getPaymentResponse.body(), UTF_8)) { var body = new MolliePaymentDetails(JsonParser.parseReader(responseReader).getAsJsonObject()); @@ -294,29 +294,37 @@ private PaymentResult initPayment(TicketReservation reservation, PaymentSpecification spec, String baseUrl, Map configuration) throws IOException, InterruptedException { - var event = spec.getEvent(); - var eventName = event.getShortName(); + var purchaseContext = spec.getPurchaseContext(); + var purchaseContextUrlComponent = purchaseContext.getType().getUrlComponent(); + var publicIdentifier = purchaseContext.getPublicIdentifier(); var reservationId = reservation.getId(); - String bookUrl = baseUrl + "/event/" + eventName + "/reservation/" + reservationId + "/book"; - int tickets = ticketRepository.countTicketsInReservation(reservation.getId()); + String bookUrl = baseUrl + "/" + purchaseContextUrlComponent + "/" + publicIdentifier + "/reservation/" + reservationId + "/book"; + final int items; + if(spec.getPurchaseContext().getType() == PurchaseContext.PurchaseContextType.event) { + items = ticketRepository.countTicketsInReservation(spec.getReservationId()); + } else { + items = 1; + } + Map payload = new HashMap<>(); - payload.put("amount", Map.of("value", spec.getOrderSummary().getTotalPrice(), "currency", spec.getEvent().getCurrency())); - payload.put("description", String.format("%s - %d ticket(s) for event %s", configurationManager.getShortReservationID(spec.getEvent(), reservation), tickets, spec.getEvent().getDisplayName())); + payload.put("amount", Map.of("value", spec.getOrderSummary().getTotalPrice(), "currency", spec.getPurchaseContext().getCurrency())); + var description = purchaseContext.getType() == PurchaseContext.PurchaseContextType.event ? "ticket(s) for event" : "x subscription"; + payload.put("description", String.format("%s - %d %s %s", configurationManager.getShortReservationID(spec.getPurchaseContext(), reservation), items, description, spec.getPurchaseContext().getDisplayName())); payload.put("redirectUrl", bookUrl); - payload.put("webhookUrl", baseUrl + UriComponentsBuilder.fromPath(WEBHOOK_URL_TEMPLATE).buildAndExpand(eventName, reservationId).toUriString()); + payload.put("webhookUrl", baseUrl + UriComponentsBuilder.fromPath(WEBHOOK_URL_TEMPLATE).buildAndExpand(reservationId).toUriString()); payload.put("metadata", MetadataBuilder.buildMetadata(spec, Map.of())); if(configuration.get(PLATFORM_MODE_ENABLED).getValueAsBooleanOrDefault()) { payload.put("profileId", configuration.get(MOLLIE_CONNECT_PROFILE_ID).getRequiredValue()); payload.put("testmode", !configuration.get(MOLLIE_CONNECT_LIVE_MODE).getValueAsBooleanOrDefault()); String currencyCode = spec.getCurrencyCode(); - FeeCalculator.getCalculator(spec.getEvent(), configurationManager, currencyCode).apply(tickets, (long) spec.getPriceWithVAT()) + FeeCalculator.getCalculator(spec.getPurchaseContext(), configurationManager, currencyCode).apply(items, (long) spec.getPriceWithVAT()) .filter(fee -> fee > 1L) //minimum fee for Mollie is 0.01 .map(fee -> MonetaryUtil.formatCents(fee, currencyCode)) .ifPresent(fee -> payload.put("applicationFee", Map.of("amount", Map.of("currency", currencyCode, "value", fee), "description", "Reservation" + reservationId))); } - HttpRequest request = requestFor(PAYMENTS_ENDPOINT, configuration, ConfigurationLevel.event(spec.getEvent())) + HttpRequest request = requestFor(PAYMENTS_ENDPOINT, configuration, spec.getPurchaseContext().getConfigurationLevel()) .header(HttpUtils.CONTENT_TYPE, HttpUtils.APPLICATION_JSON) .POST(HttpRequest.BodyPublishers.ofString(Json.GSON.toJson(payload))) .build(); @@ -331,8 +339,8 @@ private PaymentResult initPayment(TicketReservation reservation, ticketReservationRepository.updateValidity(reservationId, Date.from(expiration.toInstant())); invalidateExistingTransactions(reservationId, transactionRepository); transactionRepository.insert(paymentId, paymentId, - reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getEvent().getZoneId())), - spec.getPriceWithVAT(), spec.getEvent().getCurrency(), "Mollie Payment", + reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getPurchaseContext().getZoneId())), + spec.getPriceWithVAT(), spec.getPurchaseContext().getCurrency(), "Mollie Payment", PaymentProxy.MOLLIE.name(), 0L,0L, Transaction.Status.PENDING, Map.of()); return PaymentResult.redirect(checkoutLink); } @@ -376,13 +384,20 @@ public String getWebhookSignatureKey() { return null; } + public static final String ADDITIONAL_INFO_PURCHASE_CONTEXT_TYPE = "purchaseContextType"; + public static final String ADDITIONAL_INFO_PURCHASE_IDENTIFIER = "purchaseContextType"; + public static final String ADDITIONAL_INFO_RESERVATION_ID = "reservationId"; + @Override public Optional parseTransactionPayload(String body, String signature, Map additionalInfo) { try(var reader = new StringReader(body)) { Properties properties = new Properties(); properties.load(reader); return Optional.ofNullable(StringUtils.trimToNull(properties.getProperty("id"))) - .map(paymentId -> new MollieWebhookPayload(paymentId, additionalInfo.get("eventName"), additionalInfo.get("reservationId"))); + .map(paymentId -> new MollieWebhookPayload(paymentId, PurchaseContext.PurchaseContextType.from( + additionalInfo.get(ADDITIONAL_INFO_PURCHASE_CONTEXT_TYPE)), + additionalInfo.get(ADDITIONAL_INFO_PURCHASE_IDENTIFIER), + additionalInfo.get(ADDITIONAL_INFO_RESERVATION_ID))); } catch(Exception e) { log.warn("got exception while trying to decode Mollie Webhook Payload", e); } @@ -401,18 +416,17 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, var molliePayload = (MollieWebhookPayload)payload; var paymentId = molliePayload.getPaymentId(); - var eventShortName = molliePayload.getEventName(); - var optionalEvent = eventRepository.findOptionalByShortName(eventShortName); - if(optionalEvent.isEmpty()) { + var optionalPurchaseContext = purchaseContextManager.findBy(molliePayload.getPurchaseContextType(), molliePayload.getPurchaseContextIdentifier()); + if(optionalPurchaseContext.isEmpty()) { return PaymentWebhookResult.notRelevant("event"); } - var event = optionalEvent.get(); - return validateRemotePayment(transaction, paymentContext, paymentId, event); + var purchaseContext = optionalPurchaseContext.get(); + return validateRemotePayment(transaction, paymentContext, paymentId, purchaseContext); } - private PaymentWebhookResult validateRemotePayment(Transaction transaction, PaymentContext paymentContext, String paymentId, Event event) { + private PaymentWebhookResult validateRemotePayment(Transaction transaction, PaymentContext paymentContext, String paymentId, PurchaseContext purchaseContext) { try { - var configuration = getConfiguration(ConfigurationLevel.event(event)); + var configuration = getConfiguration(purchaseContext.getConfigurationLevel()); HttpResponse response = callGetPayment(paymentId, configuration, paymentContext.getConfigurationLevel()); if(HttpUtils.callSuccessful(response)) { try (var reader = new InputStreamReader(response.body(), UTF_8)) { @@ -444,11 +458,11 @@ private PaymentWebhookResult validateRemotePayment(Transaction transaction, Paym case "failed": case "expired": transactionMetadata.put("paymentMethod", Optional.ofNullable(body.getPaymentMethod()).map(PaymentMethod::name).orElse(null)); - transactionRepository.update(transaction.getId(), paymentId, paymentId, event.now(clockProvider), + transactionRepository.update(transaction.getId(), paymentId, paymentId, purchaseContext.now(clockProvider), transaction.getPlatformFee(), transaction.getGatewayFee(), transaction.getStatus(), transactionMetadata); return status.equals("failed") ? PaymentWebhookResult.failed("failed") : PaymentWebhookResult.cancelled(); case "canceled": - transactionRepository.update(transaction.getId(), paymentId, paymentId, event.now(clockProvider), + transactionRepository.update(transaction.getId(), paymentId, paymentId, purchaseContext.now(clockProvider), 0L, 0L, Transaction.Status.CANCELLED, transaction.getMetadata()); return PaymentWebhookResult.cancelled(); case "open": @@ -462,7 +476,7 @@ private PaymentWebhookResult validateRemotePayment(Transaction transaction, Paym log.warn("Received suspicious call for non-existent payment id "+paymentId); return PaymentWebhookResult.notRelevant(""); } - log.warn("was not able to get payment id " + paymentId + " for event " + event.getShortName()); + log.warn("was not able to get payment id " + paymentId + " for purchaseContext of type " + purchaseContext.getType() + " with public identifier " + purchaseContext.getPublicIdentifier()); return PaymentWebhookResult.error("internal error"); } } catch(Exception ex) { @@ -473,7 +487,7 @@ private PaymentWebhookResult validateRemotePayment(Transaction transaction, Paym @Override public PaymentWebhookResult forceTransactionCheck(TicketReservation reservation, Transaction transaction, PaymentContext paymentContext) { - return validateRemotePayment(transaction, paymentContext, transaction.getPaymentId(), paymentContext.getEvent()); + return validateRemotePayment(transaction, paymentContext, transaction.getPaymentId(), paymentContext.getPurchaseContext()); } private HttpResponse callGetPayment(String paymentId, Map configuration, ConfigurationLevel configurationLevel) throws IOException, InterruptedException { @@ -486,13 +500,13 @@ private HttpResponse callGetPayment(String paymentId, Map MonetaryUtil.formatCents(a, currencyCode)) .orElseGet(transaction::getFormattedAmount); log.trace("Attempting to refund {} for reservation {}", amountToRefund, transaction.getReservationId()); - var configurationLevel = ConfigurationLevel.event(event); + var configurationLevel = purchaseContext.getConfigurationLevel(); var configuration = getConfiguration(configurationLevel); var paymentId = transaction.getPaymentId(); var parameters = new HashMap(); @@ -526,8 +540,8 @@ public boolean refund(Transaction transaction, Event event, Integer amount) { } @Override - public Optional getInfo(Transaction transaction, Event event) { - ConfigurationLevel configurationLevel = ConfigurationLevel.event(event); + public Optional getInfo(Transaction transaction, PurchaseContext purchaseContext) { + ConfigurationLevel configurationLevel = purchaseContext.getConfigurationLevel(); var configuration = getConfiguration(configurationLevel); try { var getPaymentResponse = callGetPayment(transaction.getPaymentId(), configuration, configurationLevel); diff --git a/src/main/java/alfio/manager/payment/OnSiteManager.java b/src/main/java/alfio/manager/payment/OnSiteManager.java index c9ec310ac1..88307bfcc9 100644 --- a/src/main/java/alfio/manager/payment/OnSiteManager.java +++ b/src/main/java/alfio/manager/payment/OnSiteManager.java @@ -72,7 +72,7 @@ public PaymentMethod getPaymentMethodForTransaction(Transaction transaction) { @Override public boolean isActive(PaymentContext paymentContext) { return configurationManager.getFor(ON_SITE_ENABLED, paymentContext.getConfigurationLevel()).getValueAsBooleanOrDefault() - && (paymentContext.getConfigurationLevel().getPathLevel() != ConfigurationPathLevel.EVENT || !paymentContext.getEvent().isOnline()); + && (paymentContext.getConfigurationLevel().getPathLevel() != ConfigurationPathLevel.EVENT || !paymentContext.isOnline()); } @Override diff --git a/src/main/java/alfio/manager/payment/PayPalManager.java b/src/main/java/alfio/manager/payment/PayPalManager.java index a879ece250..7931ce6746 100644 --- a/src/main/java/alfio/manager/payment/PayPalManager.java +++ b/src/main/java/alfio/manager/payment/PayPalManager.java @@ -17,10 +17,8 @@ package alfio.manager.payment; import alfio.manager.PaymentManager; -import alfio.manager.i18n.MessageSourceManager; import alfio.manager.support.FeeCalculator; import alfio.manager.support.PaymentResult; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.system.ConfigurationKeys; @@ -75,15 +73,14 @@ public class PayPalManager implements PaymentProvider, RefundRequest, PaymentInf .expireAfterAccess(Duration.ofHours(1L)) .build(); private final ConfigurationManager configurationManager; - private final MessageSourceManager messageSourceManager; private final TicketReservationRepository ticketReservationRepository; private final TicketRepository ticketRepository; private final TransactionRepository transactionRepository; private final Json json; private final ClockProvider clockProvider; - private PayPalHttpClient getClient(EventAndOrganizationId event) { - PayPalEnvironment apiContext = getApiContext(event); + private PayPalHttpClient getClient(Configurable configurable) { + PayPalEnvironment apiContext = getApiContext(configurable); return cachedClients.get(generateKey(apiContext), key -> new PayPalHttpClient(apiContext)); } @@ -91,9 +88,9 @@ private String generateKey(PayPalEnvironment environment) { return DigestUtils.sha256Hex(environment.baseUrl() + "::" + environment.clientId() + "::" + environment.clientSecret()); } - private PayPalEnvironment getApiContext(EventAndOrganizationId event) { + private PayPalEnvironment getApiContext(Configurable configurable) { var paypalConf = configurationManager.getFor(Set.of(ConfigurationKeys.PAYPAL_LIVE_MODE, ConfigurationKeys.PAYPAL_CLIENT_ID, ConfigurationKeys.PAYPAL_CLIENT_SECRET), - ConfigurationLevel.event(event)); + configurable.getConfigurationLevel()); boolean isLive = paypalConf.get(ConfigurationKeys.PAYPAL_LIVE_MODE).getValueAsBooleanOrDefault(); String clientId = paypalConf.get(ConfigurationKeys.PAYPAL_CLIENT_ID).getRequiredValue(); String clientSecret = paypalConf.get(ConfigurationKeys.PAYPAL_CLIENT_SECRET).getRequiredValue(); @@ -105,12 +102,13 @@ private PayPalEnvironment getApiContext(EventAndOrganizationId event) { private String createCheckoutRequest(PaymentSpecification spec) throws Exception { TicketReservation reservation = ticketReservationRepository.findReservationById(spec.getReservationId()); - String eventName = spec.getEvent().getShortName(); + String purchaseContextType = spec.getPurchaseContext().getType().getUrlComponent(); + String publicIdentifier = spec.getPurchaseContext().getPublicIdentifier(); - String baseUrl = StringUtils.removeEnd(configurationManager.getFor(ConfigurationKeys.BASE_URL, ConfigurationLevel.event(spec.getEvent())).getRequiredValue(), "/"); - String bookUrl = baseUrl+"/event/" + eventName + "/reservation/" + spec.getReservationId() + "/payment/paypal/" + URL_PLACEHOLDER; + String baseUrl = StringUtils.removeEnd(configurationManager.getFor(ConfigurationKeys.BASE_URL, spec.getPurchaseContext().getConfigurationLevel()).getRequiredValue(), "/"); + String bookUrl = baseUrl + "/" + purchaseContextType + "/" + publicIdentifier + "/reservation/" + spec.getReservationId() + "/payment/paypal/" + URL_PLACEHOLDER; - String hmac = computeHMAC(spec.getCustomerName(), spec.getEmail(), spec.getBillingAddress(), spec.getEvent()); + String hmac = computeHMAC(spec.getCustomerName(), spec.getEmail(), spec.getBillingAddress(), spec.getPurchaseContext()); UriComponentsBuilder bookUrlBuilder = UriComponentsBuilder.fromUriString(bookUrl) .queryParam("hmac", hmac); String finalUrl = bookUrlBuilder.toUriString(); @@ -129,16 +127,16 @@ private String createCheckoutRequest(PaymentSpecification spec) throws Exception OrdersCreateRequest request = new OrdersCreateRequest().requestBody(orderRequest); request.header("prefer","return=representation"); request.header("PayPal-Request-Id", reservation.getId()); - HttpResponse response = getClient(spec.getEvent()).execute(request); + HttpResponse response = getClient(spec.getPurchaseContext()).execute(request); if(HttpUtils.statusCodeIsSuccessful(response.statusCode())) { Order order = response.result(); var status = order.status(); if("APPROVED".equals(status) || "COMPLETED".equals(status)) { if("APPROVED".equals(status)) { - saveToken(reservation.getId(), spec.getEvent(), new PayPalToken(order.payer().payerId(), order.id(), hmac)); + saveToken(reservation.getId(), spec.getPurchaseContext(), new PayPalToken(order.payer().payerId(), order.id(), hmac)); } - return "/event/"+spec.getEvent().getShortName()+"/reservation/"+spec.getReservationId(); + return "/" + purchaseContextType + "/" + spec.getPurchaseContext().getPublicIdentifier() + "/reservation/" + spec.getReservationId(); } else if("CREATED".equals(status)) { //add 15 minutes of validity in case the paypal flow is slow ticketReservationRepository.updateValidity(spec.getReservationId(), DateUtils.addMinutes(reservation.getValidity(), 15)); @@ -149,12 +147,12 @@ private String createCheckoutRequest(PaymentSpecification spec) throws Exception throw new IllegalStateException(); } - private static String computeHMAC(CustomerName customerName, String email, String billingAddress, Event event) { - return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, event.getPrivateKey()).hmacHex(StringUtils.trimToEmpty(customerName.getFullName()) + StringUtils.trimToEmpty(email) + StringUtils.trimToEmpty(billingAddress)); + private static String computeHMAC(CustomerName customerName, String email, String billingAddress, PurchaseContext purchaseContext) { + return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, purchaseContext.getPrivateKey()).hmacHex(StringUtils.trimToEmpty(customerName.getFullName()) + StringUtils.trimToEmpty(email) + StringUtils.trimToEmpty(billingAddress)); } - private static boolean isValidHMAC(CustomerName customerName, String email, String billingAddress, String hmac, Event event) { - String computedHmac = computeHMAC(customerName, email, billingAddress, event); + private static boolean isValidHMAC(CustomerName customerName, String email, String billingAddress, String hmac, PurchaseContext purchaseContext) { + String computedHmac = computeHMAC(customerName, email, billingAddress, purchaseContext); return MessageDigest.isEqual(hmac.getBytes(StandardCharsets.UTF_8), computedHmac.getBytes(StandardCharsets.UTF_8)); } @@ -178,13 +176,13 @@ private static Optional mappedException(HttpException e) { } } - private PayPalChargeDetails commitPayment(String reservationId, PayPalToken payPalToken, EventAndOrganizationId event) throws HttpException { + private PayPalChargeDetails commitPayment(String reservationId, PayPalToken payPalToken, PurchaseContext purchaseContext) throws HttpException { try { OrdersCaptureRequest request = new OrdersCaptureRequest(payPalToken.getPaymentId()).payPalRequestId(reservationId); request.header("prefer","return=representation");//force the API to reply with the full object request.requestBody(new OrderRequest()); - HttpResponse response = getClient(event).execute(request); + HttpResponse response = getClient(purchaseContext).execute(request); if(HttpUtils.statusCodeIsSuccessful(response.statusCode())) { var result = response.result(); @@ -223,14 +221,14 @@ private PayPalChargeDetails commitPayment(String reservationId, PayPalToken payP throw new IllegalStateException("cannot commit payment"); } - private Optional getInfo(Transaction transaction, EventAndOrganizationId event, Supplier platformFeeSupplier) { + private Optional getInfo(Transaction transaction, PurchaseContext purchaseContext, Supplier platformFeeSupplier) { String transactionId = transaction.getTransactionId(); String paymentId = transaction.getPaymentId(); String currency = transaction.getCurrency(); try { if(paymentId != null) { - var orderResponse = getClient(event).execute(new OrdersGetRequest(paymentId)); + var orderResponse = getClient(purchaseContext).execute(new OrdersGetRequest(paymentId)); if(HttpUtils.statusCodeIsSuccessful(orderResponse.statusCode()) && orderResponse.result() != null) { var order = orderResponse.result(); var payments = order.purchaseUnits().stream() @@ -258,12 +256,12 @@ private Optional getInfo(Transaction transaction, EventAndOr } @Override - public Optional getInfo(alfio.model.transaction.Transaction transaction, Event event) { - return getInfo(transaction, event, () -> { + public Optional getInfo(alfio.model.transaction.Transaction transaction, PurchaseContext purchaseContext) { + return getInfo(transaction, purchaseContext, () -> { if(transaction.getPlatformFee() > 0) { return String.valueOf(transaction.getPlatformFee()); } - return FeeCalculator.getCalculator(event, configurationManager, transaction.getCurrency()) + return FeeCalculator.getCalculator(purchaseContext, configurationManager, transaction.getCurrency()) .apply(ticketRepository.countTicketsInReservation(transaction.getReservationId()), (long) transaction.getPriceInCents()) .map(String::valueOf) .orElse("0"); @@ -272,12 +270,12 @@ public Optional getInfo(alfio.model.transaction.Transaction @Override - public boolean refund(alfio.model.transaction.Transaction transaction, Event event, Integer amountToRefund) { + public boolean refund(alfio.model.transaction.Transaction transaction, PurchaseContext purchaseContext, Integer amountToRefund) { Optional amount = Optional.ofNullable(amountToRefund); String captureId = transaction.getTransactionId(); try { - var payPalClient = getClient(event); + var payPalClient = getClient(purchaseContext); var refundRequest = new CapturesRefundRequest(captureId); String currency = transaction.getCurrency(); String amountOrFull = amount.map(a -> MonetaryUtil.formatCents(a, currency)).orElse("full"); @@ -350,11 +348,11 @@ public PaymentResult getToken(PaymentSpecification spec) { public PaymentResult doPayment(PaymentSpecification spec) { try { PayPalToken gatewayToken = (PayPalToken) spec.getGatewayToken(); - if(!isValidHMAC(spec.getCustomerName(), spec.getEmail(), spec.getBillingAddress(), gatewayToken.getHmac(), spec.getEvent())) { + if(!isValidHMAC(spec.getCustomerName(), spec.getEmail(), spec.getBillingAddress(), gatewayToken.getHmac(), spec.getPurchaseContext())) { return PaymentResult.failed(ErrorsCode.STEP_2_INVALID_HMAC); } - var chargeDetails = commitPayment(spec.getReservationId(), gatewayToken, spec.getEvent()); - long applicationFee = FeeCalculator.getCalculator(spec.getEvent(), configurationManager, spec.getCurrencyCode()) + var chargeDetails = commitPayment(spec.getReservationId(), gatewayToken, spec.getPurchaseContext()); + long applicationFee = FeeCalculator.getCalculator(spec.getPurchaseContext(), configurationManager, spec.getCurrencyCode()) .apply(ticketRepository.countTicketsInReservation(spec.getReservationId()), (long) spec.getPriceWithVAT()) .orElse(0L); @@ -365,7 +363,7 @@ public PaymentResult doPayment(PaymentSpecification spec) { } else { PaymentManagerUtils.invalidateExistingTransactions(spec.getReservationId(), transactionRepository); transactionRepository.insert(chargeDetails.captureId, chargeDetails.orderId, spec.getReservationId(), - ZonedDateTime.now(clockProvider.withZone(spec.getEvent().getZoneId())), spec.getPriceWithVAT(), spec.getEvent().getCurrency(), "Paypal confirmation", PaymentProxy.PAYPAL.name(), + ZonedDateTime.now(clockProvider.withZone(spec.getPurchaseContext().getZoneId())), spec.getPriceWithVAT(), spec.getPurchaseContext().getCurrency(), "Paypal confirmation", PaymentProxy.PAYPAL.name(), applicationFee, chargeDetails.payPalFee, alfio.model.transaction.Transaction.Status.COMPLETE, Map.of()); } return PaymentResult.successful(chargeDetails.captureId); @@ -380,10 +378,10 @@ public PaymentResult doPayment(PaymentSpecification spec) { } } - public void saveToken(String reservationId, Event event, PayPalToken token) { + public void saveToken(String reservationId, PurchaseContext purchaseContext, PayPalToken token) { PaymentManagerUtils.invalidateExistingTransactions(reservationId, transactionRepository); transactionRepository.insert(reservationId, token.getPaymentId(), reservationId, - event.now(clockProvider), 0, event.getCurrency(), "Paypal token", PaymentProxy.PAYPAL.name(), 0, 0, + purchaseContext.now(clockProvider), 0, purchaseContext.getCurrency(), "Paypal token", PaymentProxy.PAYPAL.name(), 0, 0, alfio.model.transaction.Transaction.Status.PENDING, Map.of(PaymentManager.PAYMENT_TOKEN, json.asJsonString(token))); } diff --git a/src/main/java/alfio/manager/payment/PaymentSpecification.java b/src/main/java/alfio/manager/payment/PaymentSpecification.java index 07004b7a23..cec14d826f 100644 --- a/src/main/java/alfio/manager/payment/PaymentSpecification.java +++ b/src/main/java/alfio/manager/payment/PaymentSpecification.java @@ -29,7 +29,7 @@ public class PaymentSpecification { private final String reservationId; private final PaymentToken gatewayToken; private final int priceWithVAT; - private final Event event; + private final PurchaseContext purchaseContext; private final String email; private final CustomerName customerName; private final String billingAddress; @@ -47,7 +47,7 @@ public class PaymentSpecification { public PaymentSpecification( String reservationId, PaymentToken gatewayToken, int priceWithVAT, - Event event, + PurchaseContext purchaseContext, String email, CustomerName customerName, String billingAddress, @@ -64,7 +64,7 @@ public PaymentSpecification( String reservationId, this.reservationId = reservationId; this.gatewayToken = gatewayToken; this.priceWithVAT = priceWithVAT; - this.event = event; + this.purchaseContext = purchaseContext; this.email = email; this.customerName = customerName; this.billingAddress = billingAddress; @@ -82,28 +82,28 @@ public PaymentSpecification( String reservationId, public PaymentSpecification(TicketReservation reservation, TotalPrice totalPrice, - Event event, + PurchaseContext purchaseContext, PaymentToken gatewayToken, OrderSummary orderSummary, boolean tcAccepted, boolean privacyAccepted) { this(reservation.getId(), gatewayToken, totalPrice.getPriceWithVAT(), - event, reservation.getEmail(), new CustomerName(reservation.getFullName(), reservation.getFirstName(), reservation.getLastName(), event.mustUseFirstAndLastName()), + purchaseContext, reservation.getEmail(), new CustomerName(reservation.getFullName(), reservation.getFirstName(), reservation.getLastName(), true), reservation.getBillingAddress(), reservation.getCustomerReference(), LocaleUtil.forLanguageTag(reservation.getUserLanguage()), reservation.isInvoiceRequested(), !reservation.isDirectAssignmentRequested(), orderSummary, reservation.getVatCountryCode(), reservation.getVatNr(), reservation.getVatStatus(), tcAccepted, privacyAccepted); } - PaymentSpecification( String reservationId, PaymentToken gatewayToken, int priceWithVAT, Event event, String email, CustomerName customerName ) { - this(reservationId, gatewayToken, priceWithVAT, event, email, customerName, null, null, null, false, false, null, null, null, null, false, false); + PaymentSpecification(String reservationId, PaymentToken gatewayToken, int priceWithVAT, PurchaseContext purchaseContext, String email, CustomerName customerName ) { + this(reservationId, gatewayToken, priceWithVAT, purchaseContext, email, customerName, null, null, null, false, false, null, null, null, null, false, false); } public String getCurrencyCode() { - return event.getCurrency(); + return purchaseContext.getCurrency(); } public PaymentContext getPaymentContext() { - return new PaymentContext(event); + return new PaymentContext(purchaseContext); } } diff --git a/src/main/java/alfio/manager/payment/RevolutBankTransferManager.java b/src/main/java/alfio/manager/payment/RevolutBankTransferManager.java index b52f142150..e11fb0c585 100644 --- a/src/main/java/alfio/manager/payment/RevolutBankTransferManager.java +++ b/src/main/java/alfio/manager/payment/RevolutBankTransferManager.java @@ -18,8 +18,8 @@ import alfio.manager.support.PaymentResult; import alfio.manager.system.ConfigurationManager; -import alfio.model.Event; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; import alfio.model.TicketReservationWithTransaction; import alfio.model.result.ErrorCode; import alfio.model.result.Result; @@ -48,7 +48,6 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.*; import java.util.function.Predicate; @@ -64,7 +63,6 @@ @AllArgsConstructor public class RevolutBankTransferManager implements PaymentProvider, OfflineProcessor, PaymentInfo { - private static final ZoneId UTC = ZoneId.of("UTC"); private final BankTransferManager bankTransferManager; private final ConfigurationManager configurationManager; private final TransactionRepository transactionRepository; @@ -165,7 +163,7 @@ private Predicate transactionMatches(TicketReserva var reservation = reservationWithTransaction.getTicketReservation(); var transaction = reservationWithTransaction.getTransaction(); String reservationId = reservation.getId().toLowerCase(); - var shortReservationId = configurationManager.getShortReservationID(context.getEvent(), reservation).toLowerCase(); + var shortReservationId = configurationManager.getShortReservationID(context.getPurchaseContext(), reservation).toLowerCase(); String[] terms; if(reservation.getHasInvoiceNumber()) { terms = new String[] {reservation.getInvoiceNumber().toLowerCase(), shortReservationId, reservationId}; @@ -233,7 +231,7 @@ private List loadAccountsFromAPI(String revolutKey, String baseUrl) { @Override - public Optional getInfo(Transaction transaction, Event event) { + public Optional getInfo(Transaction transaction, PurchaseContext purchaseContext) { var metadata = transaction.getMetadata(); if(metadata != null && metadata.containsKey("counterpartyAccountId")) { return Optional.of(new PaymentInformation(MonetaryUtil.formatCents(transaction.getPriceInCents(), transaction.getCurrency()), null, String.valueOf(transaction.getGatewayFee()), String.valueOf(transaction.getPlatformFee()))); diff --git a/src/main/java/alfio/manager/payment/SaferpayManager.java b/src/main/java/alfio/manager/payment/SaferpayManager.java index 2bc1272139..1b1ceeaf49 100644 --- a/src/main/java/alfio/manager/payment/SaferpayManager.java +++ b/src/main/java/alfio/manager/payment/SaferpayManager.java @@ -19,10 +19,9 @@ import alfio.manager.payment.saferpay.*; import alfio.manager.support.PaymentResult; import alfio.manager.support.PaymentWebhookResult; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; -import alfio.model.Event; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; import alfio.model.TicketReservation; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; @@ -112,11 +111,16 @@ public boolean isActive(PaymentContext paymentContext) { @Override public PaymentResult doPayment(PaymentSpecification spec) { - var event = spec.getEvent(); - var configuration = loadConfiguration(event); + var purchaseContext = spec.getPurchaseContext(); + var configuration = loadConfiguration(purchaseContext); var reservationId = spec.getReservationId(); var reservation = ticketReservationRepository.findReservationById(reservationId); - int tickets = ticketRepository.countTicketsInReservation(reservationId); + final int items; + if(spec.getPurchaseContext().getType() == PurchaseContext.PurchaseContextType.event) { + items = ticketRepository.countTicketsInReservation(spec.getReservationId()); + } else { + items = 1; + } int retryCount = 0; var existingTransaction = transactionRepository.loadOptionalByStatusAndPaymentProxyForUpdate(reservationId, Transaction.Status.PENDING, PaymentProxy.SAFERPAY); if(existingTransaction.isPresent()) { @@ -129,7 +133,8 @@ public PaymentResult doPayment(PaymentSpecification spec) { } } - var paymentDescription = String.format("%s - %d ticket(s) for event %s", configurationManager.getShortReservationID(event, reservation), tickets, event.getDisplayName()); + var description = purchaseContext.getType() == PurchaseContext.PurchaseContextType.event ? "ticket(s) for event" : "x subscription"; + var paymentDescription = String.format("%s - %d %s %s", configurationManager.getShortReservationID(purchaseContext, reservation), items, description, purchaseContext.getDisplayName()); var requestBody = new PaymentPageInitializeRequestBuilder(configuration.get(BASE_URL).getRequiredValue(), spec) .addAuthentication(configuration.get(SAFERPAY_CUSTOMER_ID).getRequiredValue(), reservationId, configuration.get(SAFERPAY_TERMINAL_ID).getRequiredValue()) .addOrderInformation(reservationId, Integer.toString(spec.getPriceWithVAT()), spec.getCurrencyCode(), paymentDescription, retryCount) @@ -173,7 +178,7 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr PaymentWebhookResult internalProcessWebhook(Transaction transaction, PaymentContext paymentContext) { int retryCount = Integer.parseInt(transaction.getMetadata().getOrDefault("retryCount", "0")); - var configuration = loadConfiguration(paymentContext.getEvent()); + var configuration = loadConfiguration(paymentContext.getPurchaseContext()); var paymentStatus = retrievePaymentStatus(configuration, transaction.getPaymentId(), transaction.getReservationId(), retryCount); if(paymentStatus.isEmpty()) { LOGGER.debug("Invalidating transaction with ID {}", transaction.getId()); @@ -204,8 +209,8 @@ public boolean requiresSignedBody() { } @Override - public Optional getInfo(Transaction transaction, Event event) { - var configuration = loadConfiguration(event); + public Optional getInfo(Transaction transaction, PurchaseContext purchaseContext) { + var configuration = loadConfiguration(purchaseContext); var requestBody = new TransactionInquireRequestBuilder(transaction.getTransactionId(), 0) .addAuthentication(configuration.get(SAFERPAY_CUSTOMER_ID).getRequiredValue(), transaction.getReservationId()) .build(); @@ -230,8 +235,8 @@ public Optional getInfo(Transaction transaction, Event event } //@Override - public boolean refund(Transaction transaction, Event event, Integer amount) { - var configuration = loadConfiguration(event); + public boolean refund(Transaction transaction, PurchaseContext purchaseContext, Integer amount) { + var configuration = loadConfiguration(purchaseContext); var requestBody = new TransactionRefundBuilder(transaction.getPaymentId(), 0) .addAuthentication(configuration.get(SAFERPAY_CUSTOMER_ID).getRequiredValue(), transaction.getReservationId()) .build(Integer.toString(amount), transaction.getCurrency()); @@ -337,8 +342,8 @@ private HttpRequest buildRequest(Map loadConfiguration(Event event) { - return configurationManager.getFor(EnumSet.of(SAFERPAY_ENABLED, SAFERPAY_API_USERNAME, SAFERPAY_API_PASSWORD, SAFERPAY_CUSTOMER_ID, SAFERPAY_TERMINAL_ID, SAFERPAY_LIVE_MODE, BASE_URL, RESERVATION_TIMEOUT), ConfigurationLevel.event(event)); + private Map loadConfiguration(PurchaseContext purchaseContext) { + return configurationManager.getFor(EnumSet.of(SAFERPAY_ENABLED, SAFERPAY_API_USERNAME, SAFERPAY_API_PASSWORD, SAFERPAY_CUSTOMER_ID, SAFERPAY_TERMINAL_ID, SAFERPAY_LIVE_MODE, BASE_URL, RESERVATION_TIMEOUT), purchaseContext.getConfigurationLevel()); } private String processPaymentInitializationResponse(HttpResponse response, PaymentSpecification spec, int retryCount) { @@ -352,8 +357,8 @@ private String processPaymentInitializationResponse(HttpResponse respons ticketReservationRepository.updateValidity(reservationId, Date.from(expiration.toInstant())); invalidateExistingTransactions(reservationId, transactionRepository); transactionRepository.insert(paymentToken, paymentToken, - reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getEvent().getZoneId())), - spec.getPriceWithVAT(), spec.getEvent().getCurrency(), "Saferpay Payment", + reservationId, ZonedDateTime.now(clockProvider.withZone(spec.getPurchaseContext().getZoneId())), + spec.getPriceWithVAT(), spec.getPurchaseContext().getCurrency(), "Saferpay Payment", PaymentProxy.SAFERPAY.name(), 0L,0L, Transaction.Status.PENDING, Map.of("retryCount", String.valueOf(retryCount))); return responseBody.get("RedirectUrl").getAsString(); diff --git a/src/main/java/alfio/manager/payment/StripeCreditCardManager.java b/src/main/java/alfio/manager/payment/StripeCreditCardManager.java index 4682db9f76..c58752354f 100644 --- a/src/main/java/alfio/manager/payment/StripeCreditCardManager.java +++ b/src/main/java/alfio/manager/payment/StripeCreditCardManager.java @@ -19,8 +19,8 @@ import alfio.manager.support.PaymentResult; import alfio.manager.system.ConfigurationManager; -import alfio.model.Event; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; import alfio.model.transaction.capabilities.ClientServerTokenRequest; @@ -88,14 +88,14 @@ public PaymentResult getToken(PaymentSpecification spec) { } @Override - public Optional getInfo(Transaction transaction, Event event) { - return baseStripeManager.getInfo(transaction, event); + public Optional getInfo(Transaction transaction, PurchaseContext purchaseContext) { + return baseStripeManager.getInfo(transaction, purchaseContext); } // https://stripe.com/docs/api#create_refund @Override - public boolean refund(Transaction transaction, Event event, Integer amountToRefund) { - return baseStripeManager.refund(transaction, event, amountToRefund); + public boolean refund(Transaction transaction, PurchaseContext purchaseContext, Integer amountToRefund) { + return baseStripeManager.refund(transaction, purchaseContext, amountToRefund); } @Override @@ -158,7 +158,7 @@ public PaymentResult doPayment( PaymentSpecification spec ) { PaymentManagerUtils.invalidateExistingTransactions(spec.getReservationId(), transactionRepository); transactionRepository.insert(charge.getId(), null, spec.getReservationId(), - ZonedDateTime.now(clockProvider.withZone(spec.getEvent().getZoneId())), spec.getPriceWithVAT(), spec.getEvent().getCurrency(), charge.getDescription(), PaymentProxy.STRIPE.name(), + ZonedDateTime.now(clockProvider.withZone(spec.getPurchaseContext().getZoneId())), spec.getPriceWithVAT(), spec.getPurchaseContext().getCurrency(), charge.getDescription(), PaymentProxy.STRIPE.name(), fees != null ? fees.getLeft() : 0L, fees != null ? fees.getRight() : 0L, Transaction.Status.COMPLETE, Map.of(STRIPE_MANAGER_TYPE_KEY, STRIPE_MANAGER)); return PaymentResult.successful(charge.getId()); }).orElseGet(() -> PaymentResult.failed("error.STEP2_UNABLE_TO_TRANSITION")); diff --git a/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java b/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java index 5ecc308a67..418e7ac21d 100644 --- a/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java +++ b/src/main/java/alfio/manager/payment/StripeWebhookPaymentManager.java @@ -18,11 +18,10 @@ import alfio.manager.support.PaymentResult; import alfio.manager.support.PaymentWebhookResult; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.Audit; -import alfio.model.Event; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; import alfio.model.TicketReservation; import alfio.model.system.ConfigurationKeys; import alfio.model.transaction.*; @@ -99,7 +98,7 @@ public TransactionInitializationToken initTransaction(PaymentSpecification payme return transactionRepository.loadOptionalByReservationId(reservationId) .map(transaction -> { if(transaction.getStatus() == Transaction.Status.PENDING) { - return buildTokenFromTransaction(transaction, paymentSpecification.getEvent(), true); + return buildTokenFromTransaction(transaction, paymentSpecification.getPurchaseContext(), true); } else { return errorToken("Reload reservation", true); } @@ -143,10 +142,10 @@ public boolean isReservationStatusChanged() { } @Override - public boolean discardTransaction(Transaction transaction, Event event) { + public boolean discardTransaction(Transaction transaction, PurchaseContext purchaseContext) { var paymentId = transaction.getPaymentId(); try { - var requestOptions = baseStripeManager.options(event).orElseThrow(); + var requestOptions = baseStripeManager.options(purchaseContext).orElseThrow(); var paymentIntent = PaymentIntent.retrieve(paymentId, requestOptions); if(cancellableStatuses.contains(paymentIntent.getStatus())) { paymentIntent.cancel(requestOptions); @@ -159,20 +158,20 @@ public boolean discardTransaction(Transaction transaction, Event event) { return false; } - private TransactionInitializationToken buildTokenFromTransaction(Transaction transaction, Event event, boolean performRemoteVerification) { + private TransactionInitializationToken buildTokenFromTransaction(Transaction transaction, PurchaseContext purchaseContext, boolean performRemoteVerification) { String clientSecret = Optional.ofNullable(transaction.getMetadata()).map(m -> m.get(CLIENT_SECRET_METADATA)).orElse(null); String chargeId = transaction.getStatus() == Transaction.Status.COMPLETE ? transaction.getTransactionId() : null; if(performRemoteVerification && transaction.getStatus() == Transaction.Status.PENDING) { // try to retrieve PaymentIntent try { - var requestOptions = baseStripeManager.options(event).orElseThrow(); + var requestOptions = baseStripeManager.options(purchaseContext).orElseThrow(); var paymentIntent = PaymentIntent.retrieve(transaction.getPaymentId(), requestOptions); var status = paymentIntent.getStatus(); if(status.equals("succeeded")) { // the existing PaymentIntent succeeded, so we can confirm the reservation log.info("marking reservation {} as paid, because PaymentIntent reports success", transaction.getReservationId()); - processSuccessfulPaymentIntent(transaction, paymentIntent, ticketReservationRepository.findReservationById(transaction.getReservationId()), event); + processSuccessfulPaymentIntent(transaction, paymentIntent, ticketReservationRepository.findReservationById(transaction.getReservationId()), purchaseContext); return errorToken("Reservation status changed", true); } else if(!status.equals("requires_payment_method")) { return errorToken("Payment in process", true); @@ -185,20 +184,20 @@ private TransactionInitializationToken buildTokenFromTransaction(Transaction tra } private StripeSCACreditCardToken createNewToken(PaymentSpecification paymentSpecification) { - Map baseMetadata = configurationManager.getFor(BASE_URL, ConfigurationLevel.event(paymentSpecification.getEvent())).getValue() + Map baseMetadata = configurationManager.getFor(BASE_URL, paymentSpecification.getPurchaseContext().getConfigurationLevel()).getValue() .map(baseUrl -> Map.of("alfioBaseUrl", baseUrl)) .orElse(Map.of()); var paymentIntentParams = baseStripeManager.createParams(paymentSpecification, baseMetadata); paymentIntentParams.put("payment_method_types", List.of("card")); try { - var options = baseStripeManager.options(paymentSpecification.getEvent(), builder -> builder.setIdempotencyKey(paymentSpecification.getReservationId())).orElseThrow(); + var options = baseStripeManager.options(paymentSpecification.getPurchaseContext(), builder -> builder.setIdempotencyKey(paymentSpecification.getReservationId())).orElseThrow(); var intent = PaymentIntent.create(paymentIntentParams, options); var clientSecret = intent.getClientSecret(); long platformFee = paymentIntentParams.containsKey("application_fee") ? (long) paymentIntentParams.get("application_fee") : 0L; PaymentManagerUtils.invalidateExistingTransactions(paymentSpecification.getReservationId(), transactionRepository); transactionRepository.insert(intent.getId(), intent.getId(), - paymentSpecification.getReservationId(), ZonedDateTime.now(clockProvider.withZone(paymentSpecification.getEvent().getZoneId())), - paymentSpecification.getPriceWithVAT(), paymentSpecification.getEvent().getCurrency(), "Payment Intent", + paymentSpecification.getReservationId(), ZonedDateTime.now(clockProvider.withZone(paymentSpecification.getPurchaseContext().getZoneId())), + paymentSpecification.getPriceWithVAT(), paymentSpecification.getPurchaseContext().getCurrency(), "Payment Intent", PaymentProxy.STRIPE.name(), platformFee,0L, Transaction.Status.PENDING, Map.of(CLIENT_SECRET_METADATA, clientSecret, STRIPE_MANAGER_TYPE_KEY, STRIPE_MANAGER)); return new StripeSCACreditCardToken(intent.getId(), null, clientSecret); @@ -253,7 +252,7 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr } boolean live = Boolean.TRUE.equals(((PaymentIntent) payload.getPayload()).getLivemode()); - if(!baseStripeManager.getSecretKey(paymentContext.getEvent()).startsWith(live ? "sk_live_" : "sk_test_")) { + if(!baseStripeManager.getSecretKey(paymentContext.getPurchaseContext()).startsWith(live ? "sk_live_" : "sk_test_")) { var description = live ? "live" : "test"; log.warn("received a {} event of type {}, which is not compatible with the current configuration", description, payload.getType()); return PaymentWebhookResult.notRelevant(description); @@ -270,16 +269,16 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr return PaymentWebhookResult.error("reservation not found"); } var reservation = optionalReservation.get(); - var event = eventRepository.findByReservationId(reservation.getId()); + var purchaseContext = paymentContext.getPurchaseContext(); switch(payload.getType()) { case PAYMENT_INTENT_CREATED: { - return PaymentWebhookResult.processStarted(buildTokenFromTransaction(transaction, event, false)); + return PaymentWebhookResult.processStarted(buildTokenFromTransaction(transaction, purchaseContext, false)); } case PAYMENT_INTENT_SUCCEEDED: { - return processSuccessfulPaymentIntent(transaction, paymentIntent, reservation, event); + return processSuccessfulPaymentIntent(transaction, paymentIntent, reservation, purchaseContext); } case PAYMENT_INTENT_PAYMENT_FAILED: { - return processFailedPaymentIntent(transaction, reservation, event); + return processFailedPaymentIntent(transaction, reservation, purchaseContext); } } @@ -298,34 +297,34 @@ public PaymentWebhookResult processWebhook(TransactionWebhookPayload payload, Tr * * @param transaction transaction * @param reservation the TicketReservation this payment belongs to - * @param event the event + * @param purchaseContext the event * @return a failed {@link PaymentWebhookResult} */ - private PaymentWebhookResult processFailedPaymentIntent(Transaction transaction, TicketReservation reservation, Event event) { + private PaymentWebhookResult processFailedPaymentIntent(Transaction transaction, TicketReservation reservation, PurchaseContext purchaseContext) { List> modifications = List.of(Map.of("paymentId", transaction.getPaymentId(), "paymentMethod", "stripe")); - auditingRepository.insert(reservation.getId(), null, event.getId(), Audit.EventType.PAYMENT_FAILED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications); + auditingRepository.insert(reservation.getId(), null, purchaseContext, Audit.EventType.PAYMENT_FAILED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications); return PaymentWebhookResult.failed("Charge has been reset by Stripe. This is usually caused by a rejection from the customer's bank"); } - private PaymentWebhookResult processSuccessfulPaymentIntent(Transaction transaction, PaymentIntent paymentIntent, TicketReservation reservation, Event event) { + private PaymentWebhookResult processSuccessfulPaymentIntent(Transaction transaction, PaymentIntent paymentIntent, TicketReservation reservation, PurchaseContext purchaseContext) { var charge = paymentIntent.getCharges().getData().get(0); var chargeId = charge.getId(); long gtwFee = Optional.ofNullable(charge.getBalanceTransactionObject()).map(BalanceTransaction::getFee).orElse(0L); transactionRepository.lockByIdForUpdate(transaction.getId());// this serializes int affectedRows = transactionRepository.updateIfStatus(transaction.getId(), chargeId, - transaction.getPaymentId(), event.now(clockProvider), transaction.getPlatformFee(), gtwFee, + transaction.getPaymentId(), purchaseContext.now(clockProvider), transaction.getPlatformFee(), gtwFee, Transaction.Status.COMPLETE, Map.of(), Transaction.Status.PENDING); List> modifications = List.of(Map.of("paymentId", chargeId, "paymentMethod", "stripe")); if(affectedRows == 0) { // the transaction was already confirmed by someone else. // We can safely return the chargeId, but we write in the auditing that we skipped the confirmation auditingRepository.insert(reservation.getId(), null, - event.getId(), Audit.EventType.PAYMENT_ALREADY_CONFIRMED, + purchaseContext, Audit.EventType.PAYMENT_ALREADY_CONFIRMED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications); return PaymentWebhookResult.successful(new StripeSCACreditCardToken(transaction.getPaymentId(), chargeId, null)); } auditingRepository.insert(reservation.getId(), null, - event.getId(), Audit.EventType.PAYMENT_CONFIRMED, + purchaseContext, Audit.EventType.PAYMENT_CONFIRMED, new Date(), Audit.EntityType.RESERVATION, reservation.getId(), modifications); return PaymentWebhookResult.successful(new StripeSCACreditCardToken(transaction.getPaymentId(), chargeId, null)); } @@ -381,13 +380,13 @@ public PaymentResult doPayment(PaymentSpecification spec) { } @Override - public Optional getInfo(Transaction transaction, Event event) { - return baseStripeManager.getInfo(transaction, event); + public Optional getInfo(Transaction transaction, PurchaseContext purchaseContext) { + return baseStripeManager.getInfo(transaction, purchaseContext); } @Override - public boolean refund(Transaction transaction, Event event, Integer amount) { - return baseStripeManager.refund(transaction, event, amount); + public boolean refund(Transaction transaction, PurchaseContext purchaseContext, Integer amount) { + return baseStripeManager.refund(transaction, purchaseContext, amount); } @Override @@ -410,8 +409,8 @@ public PaymentToken buildPaymentToken(String clientToken, PaymentContext payment public PaymentWebhookResult forceTransactionCheck(TicketReservation reservation, Transaction transaction, PaymentContext paymentContext) { Validate.isTrue(transaction.getPaymentProxy() == PaymentProxy.STRIPE, "invalid transaction"); try { - Event event = paymentContext.getEvent(); - var options = baseStripeManager.options(event, builder -> builder.setIdempotencyKey(reservation.getId())).orElseThrow(); + PurchaseContext purchaseContext = paymentContext.getPurchaseContext(); + var options = baseStripeManager.options(purchaseContext, builder -> builder.setIdempotencyKey(reservation.getId())).orElseThrow(); var intent = PaymentIntent.retrieve(transaction.getPaymentId(), options); switch(intent.getStatus()) { case "processing": @@ -419,10 +418,10 @@ public PaymentWebhookResult forceTransactionCheck(TicketReservation reservation, case "requires_confirmation": return PaymentWebhookResult.pending(); case "succeeded": - return processSuccessfulPaymentIntent(transaction, intent, reservation, event); + return processSuccessfulPaymentIntent(transaction, intent, reservation, purchaseContext); case "requires_payment_method": //payment is failed. - return processFailedPaymentIntent(transaction, reservation, event); + return processFailedPaymentIntent(transaction, reservation, purchaseContext); } return null; } catch(Exception ex) { diff --git a/src/main/java/alfio/manager/payment/saferpay/PaymentPageInitializeRequestBuilder.java b/src/main/java/alfio/manager/payment/saferpay/PaymentPageInitializeRequestBuilder.java index 836569a551..468b8e56a7 100644 --- a/src/main/java/alfio/manager/payment/saferpay/PaymentPageInitializeRequestBuilder.java +++ b/src/main/java/alfio/manager/payment/saferpay/PaymentPageInitializeRequestBuilder.java @@ -27,9 +27,9 @@ import java.util.Set; public class PaymentPageInitializeRequestBuilder { - public static final String WEBHOOK_URL_TEMPLATE = "/api/payment/webhook/saferpay/event/{eventShortName}/reservation/{reservationId}/success"; - public static final String SUCCESS_URL_TEMPLATE = "/event/{eventShortName}/reservation/{reservationId}"; - public static final String CANCEL_URL_TEMPLATE = "/event/{eventName}/reservation/{reservationId}/payment/saferpay/cancel"; + public static final String WEBHOOK_URL_TEMPLATE = "/api/payment/webhook/saferpay/reservation/{reservationId}/success"; + public static final String SUCCESS_URL_TEMPLATE = "/{purchaseContextType}/{purchaseContextIdentifier}/reservation/{reservationId}"; + public static final String CANCEL_URL_TEMPLATE = "/{purchaseContextType}/{purchaseContextIdentifier}/reservation/{reservationId}/payment/saferpay/cancel"; static final Set SUPPORTED_METHODS = Set.of( PaymentMethod.ALIPAY.name(), @@ -65,12 +65,13 @@ public class PaymentPageInitializeRequestBuilder { public PaymentPageInitializeRequestBuilder(String baseUrl, PaymentSpecification paymentSpecification) { var cleanBaseUrl = StringUtils.removeEnd(baseUrl, "/"); - var eventName = paymentSpecification.getEvent().getShortName(); + var purchaseContextType = paymentSpecification.getPurchaseContext().getType().getUrlComponent(); + var purchaseContextIdentifier = paymentSpecification.getPurchaseContext().getPublicIdentifier(); var reservationId = paymentSpecification.getReservationId(); - var eventUrl = cleanBaseUrl + expandUriTemplate(SUCCESS_URL_TEMPLATE, eventName, reservationId); + var eventUrl = cleanBaseUrl + expandUriTemplate(SUCCESS_URL_TEMPLATE, purchaseContextType, purchaseContextIdentifier, reservationId); this.successURL = eventUrl + "/book"; - this.failureURL = cleanBaseUrl + expandUriTemplate(CANCEL_URL_TEMPLATE, eventName, reservationId); - this.notifyURL = cleanBaseUrl + expandUriTemplate(WEBHOOK_URL_TEMPLATE, eventName, reservationId); + this.failureURL = cleanBaseUrl + expandUriTemplate(CANCEL_URL_TEMPLATE, purchaseContextType, purchaseContextIdentifier, reservationId); + this.notifyURL = cleanBaseUrl + expandUriTemplate(WEBHOOK_URL_TEMPLATE, reservationId); } public PaymentPageInitializeRequestBuilder addAuthentication(String customerId, String requestId, String terminalId) { @@ -131,8 +132,12 @@ private JsonWriter addPaymentMethods(JsonWriter writer) { return array.endArray(); } - private String expandUriTemplate(String template, String eventName, String reservationId) { - return UriComponentsBuilder.fromPath(template).buildAndExpand(eventName, reservationId).toUriString(); + private String expandUriTemplate(String template, String reservationId) { + return UriComponentsBuilder.fromPath(template).buildAndExpand(reservationId).toUriString(); + } + + private String expandUriTemplate(String template, String purchaseContextType, String purchaseContextIdentifier, String reservationId) { + return UriComponentsBuilder.fromPath(template).buildAndExpand(purchaseContextType, purchaseContextIdentifier, reservationId).toUriString(); } } diff --git a/src/main/java/alfio/manager/support/CustomMessageManager.java b/src/main/java/alfio/manager/support/CustomMessageManager.java index 6490a301e2..fac2a4447b 100644 --- a/src/main/java/alfio/manager/support/CustomMessageManager.java +++ b/src/main/java/alfio/manager/support/CustomMessageManager.java @@ -20,7 +20,6 @@ import alfio.manager.NotificationManager; import alfio.manager.TicketReservationManager; import alfio.manager.i18n.MessageSourceManager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.manager.system.Mailer; import alfio.model.*; @@ -76,11 +75,11 @@ public void sendMessages(String eventName, Optional categoryId, List> byLanguage = input.stream().collect(Collectors.groupingBy(m -> m.getLocale().getLanguage())); - var baseUrl = configurationManager.getFor(BASE_URL, ConfigurationLevel.event(event)).getRequiredValue(); + var baseUrl = configurationManager.getFor(BASE_URL, event.getConfigurationLevel()).getRequiredValue(); var eventMetadata = Optional.ofNullable(eventManager.getMetadataForEvent(event).getRequirementsDescriptions()); sendMessagesExecutor.execute(() -> { - var messageSource = messageSourceManager.getMessageSourceForEvent(event); + var messageSource = messageSourceManager.getMessageSourceFor(event); categoryId.map(id -> ticketRepository.findConfirmedByCategoryId(event.getId(), id)) .orElseGet(() -> ticketRepository.findAllConfirmed(event.getId())) .stream() @@ -174,7 +173,7 @@ public static Mailer.Attachment generateCalendarAttachmentForOnlineEvent(Ticket return new Mailer.Attachment(CALENDAR_ICS.fileName(""), null, CALENDAR_ICS.contentType(""), model, CALENDAR_ICS); } - private static String renderResource(String template, EventAndOrganizationId event, Model model, Locale locale, TemplateManager templateManager) { - return templateManager.renderString(event, template, model.asMap(), locale, TemplateManager.TemplateOutput.TEXT); + private static String renderResource(String template, PurchaseContext purchaseContext, Model model, Locale locale, TemplateManager templateManager) { + return templateManager.renderString(purchaseContext, template, model.asMap(), locale, TemplateManager.TemplateOutput.TEXT); } } diff --git a/src/main/java/alfio/manager/support/FeeCalculator.java b/src/main/java/alfio/manager/support/FeeCalculator.java index 82a9b3c3fc..91c20b7d85 100644 --- a/src/main/java/alfio/manager/support/FeeCalculator.java +++ b/src/main/java/alfio/manager/support/FeeCalculator.java @@ -16,9 +16,8 @@ */ package alfio.manager.support; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import alfio.util.MonetaryUtil; import java.math.BigDecimal; @@ -58,10 +57,10 @@ private long calculate(long price) { return min(maxFee, max(percentage + fixed, minFee)); } - public static BiFunction> getCalculator(EventAndOrganizationId event, ConfigurationManager configurationManager, String currencyCode) { + public static BiFunction> getCalculator(Configurable configurable, ConfigurationManager configurationManager, String currencyCode) { return (numTickets, amountInCent) -> { - if(isPlatformModeEnabled(event, configurationManager)) { - var fees = configurationManager.getFor(Set.of(PLATFORM_FIXED_FEE, PLATFORM_PERCENTAGE_FEE, PLATFORM_MINIMUM_FEE, PLATFORM_MAXIMUM_FEE), ConfigurationLevel.event(event)); + if(isPlatformModeEnabled(configurable, configurationManager)) { + var fees = configurationManager.getFor(Set.of(PLATFORM_FIXED_FEE, PLATFORM_PERCENTAGE_FEE, PLATFORM_MINIMUM_FEE, PLATFORM_MAXIMUM_FEE), configurable.getConfigurationLevel()); String fixedFee = fees.get(PLATFORM_FIXED_FEE).getValueOrDefault("0"); String percentageFee = fees.get(PLATFORM_PERCENTAGE_FEE).getValueOrDefault("0"); String minimumFee = fees.get(PLATFORM_MINIMUM_FEE).getValueOrDefault("0"); @@ -72,7 +71,7 @@ public static BiFunction> getCalculator(EventAndOr }; } - private static boolean isPlatformModeEnabled(EventAndOrganizationId event, ConfigurationManager configurationManager) { - return configurationManager.getFor(PLATFORM_MODE_ENABLED, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + private static boolean isPlatformModeEnabled(Configurable configurable, ConfigurationManager configurationManager) { + return configurationManager.getFor(PLATFORM_MODE_ENABLED, configurable.getConfigurationLevel()).getValueAsBooleanOrDefault(); } } diff --git a/src/main/java/alfio/manager/system/ConfigurationLevel.java b/src/main/java/alfio/manager/system/ConfigurationLevel.java index a7440f351d..e26638f498 100644 --- a/src/main/java/alfio/manager/system/ConfigurationLevel.java +++ b/src/main/java/alfio/manager/system/ConfigurationLevel.java @@ -17,6 +17,7 @@ package alfio.manager.system; import alfio.model.EventAndOrganizationId; +import alfio.model.PurchaseContext; import alfio.model.system.ConfigurationPathLevel; import java.util.OptionalInt; @@ -49,6 +50,13 @@ static ConfigurationLevel event(EventAndOrganizationId eventAndOrganizationId) { return new ConfigurationLevels.EventLevel(eventAndOrganizationId.getOrganizationId(), eventAndOrganizationId.getId()); } + static ConfigurationLevel purchaseContext(PurchaseContext purchaseContext) { + if(purchaseContext.getType() == PurchaseContext.PurchaseContextType.event) { + return event(purchaseContext.event().orElseThrow()); + } + return organization(purchaseContext.getOrganizationId()); + } + static ConfigurationLevel organization(int organizationId) { return new ConfigurationLevels.OrganizationLevel(organizationId); } diff --git a/src/main/java/alfio/manager/system/ConfigurationManager.java b/src/main/java/alfio/manager/system/ConfigurationManager.java index c1c1af44fd..f8719f53d9 100644 --- a/src/main/java/alfio/manager/system/ConfigurationManager.java +++ b/src/main/java/alfio/manager/system/ConfigurationManager.java @@ -23,7 +23,9 @@ import alfio.manager.system.ConfigurationLevels.EventLevel; import alfio.manager.system.ConfigurationLevels.OrganizationLevel; import alfio.manager.user.UserManager; +import alfio.model.Configurable; import alfio.model.EventAndOrganizationId; +import alfio.model.PurchaseContext; import alfio.model.TicketReservation; import alfio.model.modification.ConfigurationModification; import alfio.model.system.Configuration; @@ -288,6 +290,15 @@ public Map> loadOrganizat } } + public String getSingleConfigForOrganization(int organizationId, String keyAsString, String username) { + User user = userManager.findUserByUsername(username); + if(!userManager.isOwnerOfOrganization(user, organizationId)) { + return null; + } + var key = safeValueOf(keyAsString); + return getFirstConfigurationResult(configurationRepository.findByOrganizationAndKey(organizationId, key.name()), keyAsString); + } + public String getSingleConfigForEvent(int eventId, String keyAsString, String username) { User user = userManager.findUserByUsername(username); EventAndOrganizationId event = eventRepository.findEventAndOrganizationIdById(eventId); @@ -296,8 +307,11 @@ public String getSingleConfigForEvent(int eventId, String keyAsString, String us return null; } var key = safeValueOf(keyAsString); - return configurationRepository.findByEventAndKey(organizationId, eventId, key.name()) - .stream() + return getFirstConfigurationResult(configurationRepository.findByEventAndKey(organizationId, eventId, key.name()), keyAsString); + } + + private String getFirstConfigurationResult(List results, String keyAsString) { + return Objects.requireNonNull(results).stream() .findFirst() .map(Configuration::getValue) .or(() -> externalConfiguration.getSingle(keyAsString).map(Configuration::getValue)) @@ -318,7 +332,7 @@ public Map> loadEventConf } public Predicate areBooleanSettingsEnabledForEvent(ConfigurationKeys... keys) { - return event -> getFor(Set.of(keys), ConfigurationLevel.event(event)).entrySet().stream().allMatch(kv -> kv.getValue().getValueAsBooleanOrDefault()); + return event -> getFor(Set.of(keys), event.getConfigurationLevel()).entrySet().stream().allMatch(kv -> kv.getValue().getValueAsBooleanOrDefault()); } private static Map> removeAlfioPISettingsIfNeeded(boolean offlineCheckInEnabled, Map> settings) { @@ -427,23 +441,23 @@ private static Map> colle .collect(groupByCategory()); } - public String getShortReservationID(EventAndOrganizationId event, TicketReservation reservation) { - var conf = getFor(Set.of(USE_INVOICE_NUMBER_AS_ID, PARTIAL_RESERVATION_ID_LENGTH), ConfigurationLevel.event(event)); + public String getShortReservationID(Configurable configurable, TicketReservation reservation) { + var conf = getFor(Set.of(USE_INVOICE_NUMBER_AS_ID, PARTIAL_RESERVATION_ID_LENGTH), configurable.getConfigurationLevel()); if(conf.get(USE_INVOICE_NUMBER_AS_ID).getValueAsBooleanOrDefault() && reservation.getHasInvoiceNumber()) { return reservation.getInvoiceNumber(); } return StringUtils.substring(reservation.getId(), 0, conf.get(PARTIAL_RESERVATION_ID_LENGTH).getValueAsIntOrDefault(8)).toUpperCase(); } - public String getPublicReservationID(EventAndOrganizationId event, TicketReservation reservation) { - if(getFor(USE_INVOICE_NUMBER_AS_ID, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault() && reservation.getHasInvoiceNumber()) { + public String getPublicReservationID(Configurable configurable, TicketReservation reservation) { + if(getFor(USE_INVOICE_NUMBER_AS_ID, configurable.getConfigurationLevel()).getValueAsBooleanOrDefault() && reservation.getHasInvoiceNumber()) { return reservation.getInvoiceNumber(); } return reservation.getId(); } - public boolean hasAllConfigurationsForInvoice(EventAndOrganizationId event) { - var r = getFor(Set.of(INVOICE_ADDRESS, VAT_NR), ConfigurationLevel.event(event)); + public boolean hasAllConfigurationsForInvoice(Configurable configurable) { + var r = getFor(Set.of(INVOICE_ADDRESS, VAT_NR), configurable.getConfigurationLevel()); return hasAllConfigurationsForInvoice(r); } @@ -468,16 +482,16 @@ public boolean isRecaptchaForOfflinePaymentAndFreeEnabled(ConfigurationLevel con } // https://github.com/alfio-event/alf.io/issues/573 - public boolean canGenerateReceiptOrInvoiceToCustomer(EventAndOrganizationId event) { - return !isItalianEInvoicingEnabled(event); + public boolean canGenerateReceiptOrInvoiceToCustomer(Configurable configurable) { + return !isItalianEInvoicingEnabled(configurable); } public boolean canGenerateReceiptOrInvoiceToCustomer(Map configurationValues) { return !isItalianEInvoicingEnabled(configurationValues); } - public boolean isInvoiceOnly(EventAndOrganizationId event) { - var res = getFor(Set.of(GENERATE_ONLY_INVOICE, ENABLE_ITALY_E_INVOICING), ConfigurationLevel.event(event)); + public boolean isInvoiceOnly(Configurable configurable) { + var res = getFor(Set.of(GENERATE_ONLY_INVOICE, ENABLE_ITALY_E_INVOICING), configurable.getConfigurationLevel()); return isInvoiceOnly(res); } @@ -490,8 +504,8 @@ public boolean isInvoiceOnly(Map configur return configurationValues.get(GENERATE_ONLY_INVOICE).getValueAsBooleanOrDefault() || configurationValues.get(ENABLE_ITALY_E_INVOICING).getValueAsBooleanOrDefault(); } - public boolean isItalianEInvoicingEnabled(EventAndOrganizationId event) { - var res = getFor(List.of(ENABLE_ITALY_E_INVOICING), ConfigurationLevel.event(event)); + public boolean isItalianEInvoicingEnabled(Configurable configurable) { + var res = getFor(List.of(ENABLE_ITALY_E_INVOICING), configurable.getConfigurationLevel()); return isItalianEInvoicingEnabled(res); } @@ -553,20 +567,23 @@ private Map buildKeyConfigurationMapResul return res; } - public List getBlacklistedMethodsForReservation(EventAndOrganizationId e, Collection categoryIds) { - if(categoryIds.size() > 1) { - Map blacklistForCategories = configurationRepository.getAllCategoriesAndValueWith(e.getOrganizationId(), e.getId(), PAYMENT_METHODS_BLACKLIST); - return categoryIds.stream() - .filter(blacklistForCategories::containsKey) - .flatMap(id -> Arrays.stream(blacklistForCategories.get(id).split(","))) - .map(name -> PaymentProxy.valueOf(name).getPaymentMethod()) - .collect(toList()); - } else if (categoryIds.size() > 0) { - return configurationRepository.findByKeyAtCategoryLevel(e.getId(), e.getOrganizationId(), IterableUtils.get(categoryIds, 0), PAYMENT_METHODS_BLACKLIST.name()) - .map(v -> Arrays.stream(v.getValue().split(",")).map(name -> PaymentProxy.valueOf(name).getPaymentMethod()).collect(toList())) - .orElse(List.of()); - } - return List.of(); + public List getBlacklistedMethodsForReservation(PurchaseContext p, Collection categoryIds) { + return p.event().map(e -> { + if(categoryIds.size() > 1) { + Map blacklistForCategories = configurationRepository.getAllCategoriesAndValueWith(e.getOrganizationId(), e.getId(), PAYMENT_METHODS_BLACKLIST); + return categoryIds.stream() + .filter(blacklistForCategories::containsKey) + .flatMap(id -> Arrays.stream(blacklistForCategories.get(id).split(","))) + .map(name -> PaymentProxy.valueOf(name).getPaymentMethod()) + .collect(toList()); + } else if (categoryIds.size() > 0) { + return configurationRepository.findByKeyAtCategoryLevel(e.getId(), e.getOrganizationId(), IterableUtils.get(categoryIds, 0), PAYMENT_METHODS_BLACKLIST.name()) + .map(v -> Arrays.stream(v.getValue().split(",")).map(name -> PaymentProxy.valueOf(name).getPaymentMethod()).collect(toList())) + .orElse(List.of()); + } else { + return List.of(); + } + }).orElse(List.of()); } private static boolean toBeSaved(ConfigurationModification c) { diff --git a/src/main/java/alfio/manager/system/DataMigrator.java b/src/main/java/alfio/manager/system/DataMigrator.java index 8523d80121..f75304b238 100644 --- a/src/main/java/alfio/manager/system/DataMigrator.java +++ b/src/main/java/alfio/manager/system/DataMigrator.java @@ -167,7 +167,7 @@ private void fixReservationsForEvent(Event event, List reservations) { var promoCodeDiscountId = ticketReservation.getPromoCodeDiscountId(); var discount = promoCodeDiscountId != null ? promoCodeDiscountRepository.findById(promoCodeDiscountId) : null; var additionalServiceItems = additionalServiceItemRepository.findByReservationUuid(ticketReservation.getId()); - var calculator = new ReservationPriceCalculator(ticketReservation, discount, tickets, additionalServiceItems, additionalServices, event); + var calculator = new ReservationPriceCalculator(ticketReservation, discount, tickets, additionalServiceItems, additionalServices, event, List.of(), Optional.empty()); var currencyCode = calculator.getCurrencyCode(); return new MapSqlParameterSource("reservationId", calculator.reservation.getId()) .addValue("srcPrice", calculator.getSrcPriceCts()) diff --git a/src/main/java/alfio/manager/system/DefaultMailer.java b/src/main/java/alfio/manager/system/DefaultMailer.java index ed8a47e2ef..513a705068 100644 --- a/src/main/java/alfio/manager/system/DefaultMailer.java +++ b/src/main/java/alfio/manager/system/DefaultMailer.java @@ -16,7 +16,7 @@ */ package alfio.manager.system; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; @@ -48,16 +48,16 @@ public DefaultMailer(ConfigurationManager configurationManager, Environment envi } @Override - public void send(EventAndOrganizationId event, String fromName, String to, List cc, String subject, String text, + public void send(Configurable configurable, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachments) { subject = decorateSubjectIfDemo(subject, environment); - String mailerType = configurationManager.getFor(MAILER_TYPE, ConfigurationLevel.event(event)) + String mailerType = configurationManager.getFor(MAILER_TYPE, configurable.getConfigurationLevel()) .getValueOrDefault("disabled").toLowerCase(Locale.ENGLISH); mailers.getOrDefault(mailerType, defaultMailer) - .send(event, fromName, to, cc, subject, text, html, attachments); + .send(configurable, fromName, to, cc, subject, text, html, attachments); } } \ No newline at end of file diff --git a/src/main/java/alfio/manager/system/Mailer.java b/src/main/java/alfio/manager/system/Mailer.java index 2b073c8b8f..be9c23471b 100644 --- a/src/main/java/alfio/manager/system/Mailer.java +++ b/src/main/java/alfio/manager/system/Mailer.java @@ -17,7 +17,7 @@ package alfio.manager.system; import alfio.config.Initializer; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import lombok.Data; import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; @@ -26,7 +26,7 @@ public interface Mailer { - void send(EventAndOrganizationId event, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachment); + void send(Configurable configurable, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachment); @Data class Attachment { diff --git a/src/main/java/alfio/manager/system/MailgunMailer.java b/src/main/java/alfio/manager/system/MailgunMailer.java index 01ba9469fa..a2cbeb8341 100644 --- a/src/main/java/alfio/manager/system/MailgunMailer.java +++ b/src/main/java/alfio/manager/system/MailgunMailer.java @@ -16,7 +16,7 @@ */ package alfio.manager.system; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import alfio.util.HttpUtils; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -61,10 +61,10 @@ private static Map getEmailData(String from, String to, String r } @Override - public void send(EventAndOrganizationId event, String fromName, String to, List cc, String subject, String text, + public void send(Configurable configurable, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachment) { - var conf = configurationManager.getFor(Set.of(MAILGUN_KEY, MAILGUN_DOMAIN, MAILGUN_EU, MAILGUN_FROM, MAIL_REPLY_TO), ConfigurationLevel.event(event)); + var conf = configurationManager.getFor(Set.of(MAILGUN_KEY, MAILGUN_DOMAIN, MAILGUN_EU, MAILGUN_FROM, MAIL_REPLY_TO), configurable.getConfigurationLevel()); String apiKey = conf.get(MAILGUN_KEY).getRequiredValue(); String domain = conf.get(MAILGUN_DOMAIN).getRequiredValue(); diff --git a/src/main/java/alfio/manager/system/MailjetMailer.java b/src/main/java/alfio/manager/system/MailjetMailer.java index 7ee65a33af..4a45b28011 100644 --- a/src/main/java/alfio/manager/system/MailjetMailer.java +++ b/src/main/java/alfio/manager/system/MailjetMailer.java @@ -16,7 +16,7 @@ */ package alfio.manager.system; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import alfio.util.HttpUtils; import alfio.util.Json; import lombok.extern.log4j.Log4j2; @@ -45,9 +45,9 @@ public MailjetMailer(HttpClient httpClient, ConfigurationManager configurationMa } @Override - public void send(EventAndOrganizationId event, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachment) { + public void send(Configurable configurable, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachment) { - var conf = configurationManager.getFor(Set.of(MAILJET_APIKEY_PUBLIC, MAILJET_APIKEY_PRIVATE, MAILJET_FROM, MAIL_REPLY_TO), ConfigurationLevel.event(event)); + var conf = configurationManager.getFor(Set.of(MAILJET_APIKEY_PUBLIC, MAILJET_APIKEY_PRIVATE, MAILJET_FROM, MAIL_REPLY_TO), configurable.getConfigurationLevel()); String apiKeyPublic = conf.get(MAILJET_APIKEY_PUBLIC).getRequiredValue(); diff --git a/src/main/java/alfio/manager/system/MockMailer.java b/src/main/java/alfio/manager/system/MockMailer.java index bbe2e6c2f7..0c1d141a31 100644 --- a/src/main/java/alfio/manager/system/MockMailer.java +++ b/src/main/java/alfio/manager/system/MockMailer.java @@ -16,7 +16,7 @@ */ package alfio.manager.system; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.core.env.Environment; @@ -37,7 +37,7 @@ public class MockMailer implements Mailer { private final Environment environment; @Override - public void send(EventAndOrganizationId event, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachments) { + public void send(Configurable configurable, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachments) { subject = decorateSubjectIfDemo(subject, environment); @@ -49,7 +49,7 @@ public void send(EventAndOrganizationId event, String fromName, String to, List< log.info("Email: from: {}, replyTo: {}, to: {}, cc: {}, subject: {}, text: {}, html: {}, attachments: {}", fromName, - configurationManager.getFor(MAIL_REPLY_TO, ConfigurationLevel.event(event)).getValueOrDefault(""), + configurationManager.getFor(MAIL_REPLY_TO, configurable.getConfigurationLevel()).getValueOrDefault(""), to, cc, subject, text, html.orElse("no html"), printedAttachments); } diff --git a/src/main/java/alfio/manager/system/ReservationPriceCalculator.java b/src/main/java/alfio/manager/system/ReservationPriceCalculator.java index 06b6c0fdd1..b2eeddb786 100644 --- a/src/main/java/alfio/manager/system/ReservationPriceCalculator.java +++ b/src/main/java/alfio/manager/system/ReservationPriceCalculator.java @@ -18,12 +18,15 @@ import alfio.model.*; import alfio.model.decorator.AdditionalServiceItemPriceContainer; +import alfio.model.decorator.SubscriptionPriceContainer; import alfio.model.decorator.TicketPriceContainer; +import alfio.model.subscription.Subscription; import alfio.util.MonetaryUtil; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.Pair; import java.math.BigDecimal; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -37,37 +40,50 @@ public class ReservationPriceCalculator implements PriceContainer { final List tickets; final List additionalServiceItems; final List additionalServices; - final Event event; + final PurchaseContext purchaseContext; + private final List subscriptions; + private final Optional appliedSubscription; @Override public int getSrcPriceCts() { - return tickets.stream().mapToInt(Ticket::getSrcPriceCts).sum() + additionalServiceItems.stream().mapToInt(AdditionalServiceItem::getSrcPriceCts).sum(); + return tickets.stream().mapToInt(Ticket::getSrcPriceCts).sum() + + additionalServiceItems.stream().mapToInt(AdditionalServiceItem::getSrcPriceCts).sum() + + subscriptions.stream().mapToInt(Subscription::getSrcPriceCts).sum(); } @Override public BigDecimal getAppliedDiscount() { + + int subscriptionDiscount = appliedSubscription.flatMap(subscription -> tickets.stream() + .min(Comparator.comparing(Ticket::getFinalPriceCts))) + .map(Ticket::getSrcPriceCts) + .orElse(0); + + //FIXME check how it should be applied in case of discount if(discount != null) { if (discount.getDiscountType() == PromoCodeDiscount.DiscountType.FIXED_AMOUNT_RESERVATION) { - return MonetaryUtil.centsToUnit(discount.getDiscountAmount(), reservation.getCurrencyCode()); + return MonetaryUtil.centsToUnit(discount.getDiscountAmount() + subscriptionDiscount, reservation.getCurrencyCode()); } - return MonetaryUtil.centsToUnit(tickets.stream().mapToInt(Ticket::getDiscountCts).sum() + additionalServiceItems.stream().mapToInt(AdditionalServiceItem::getDiscountCts).sum(), reservation.getCurrencyCode()); + return MonetaryUtil.centsToUnit(tickets.stream().mapToInt(Ticket::getDiscountCts).sum() + + additionalServiceItems.stream().mapToInt(AdditionalServiceItem::getDiscountCts).sum() + + subscriptions.stream().mapToInt(Subscription::getDiscountCts).sum() + subscriptionDiscount, reservation.getCurrencyCode()); } - return BigDecimal.ZERO; + return MonetaryUtil.centsToUnit(subscriptionDiscount, reservation.getCurrencyCode()); } @Override public String getCurrencyCode() { - return event.getCurrency(); + return purchaseContext.getCurrency(); } @Override public Optional getOptionalVatPercentage() { - return Optional.ofNullable(firstNonNull(reservation.getUsedVatPercent(), event.getVat())); + return Optional.ofNullable(firstNonNull(reservation.getUsedVatPercent(), purchaseContext.getVat())); } @Override public VatStatus getVatStatus() { - return firstNonNull(reservation.getVatStatus(), event.getVatStatus()); + return firstNonNull(reservation.getVatStatus(), purchaseContext.getVatStatus()); } @Override @@ -78,12 +94,14 @@ public Optional getDiscount() { @Override public BigDecimal getTaxablePrice() { var ticketsTaxablePrice = tickets.stream() - .map(t -> TicketPriceContainer.from(t, reservation.getVatStatus(), getVatPercentageOrZero(), event.getVatStatus(), discount).getTaxablePrice()) + .map(t -> TicketPriceContainer.from(t, reservation.getVatStatus(), getVatPercentageOrZero(), purchaseContext.getVatStatus(), discount).getTaxablePrice()) .reduce(BigDecimal.ZERO, BigDecimal::add); var additionalServiceTaxablePrice = additionalServiceItems.stream() - .map(asi -> AdditionalServiceItemPriceContainer.from(asi, additionalServices.stream().filter(as -> as.getId() == asi.getAdditionalServiceId()).findFirst().orElseThrow(), event, discount).getTaxablePrice()) + .map(asi -> AdditionalServiceItemPriceContainer.from(asi, additionalServices.stream().filter(as -> as.getId() == asi.getAdditionalServiceId()).findFirst().orElseThrow(), purchaseContext, discount).getTaxablePrice()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + var subscriptionsPrice = subscriptions.stream().map(s -> SubscriptionPriceContainer.from(s, purchaseContext, discount).getTaxablePrice()) .reduce(BigDecimal.ZERO, BigDecimal::add); - var totalTicketsAndAdditional = ticketsTaxablePrice.add(additionalServiceTaxablePrice); + var totalTicketsAndAdditional = ticketsTaxablePrice.add(additionalServiceTaxablePrice).add(subscriptionsPrice); if(discount != null && discount.getDiscountType() != PromoCodeDiscount.DiscountType.FIXED_AMOUNT_RESERVATION) { // no need to add the discounted price here, since the single items are already taking it into account return totalTicketsAndAdditional; @@ -91,10 +109,11 @@ public BigDecimal getTaxablePrice() { return totalTicketsAndAdditional.subtract(getAppliedDiscount()); } - public static ReservationPriceCalculator from(TicketReservation reservation, PromoCodeDiscount discount, List tickets, Event event, List>> additionalServiceItemsByAdditionalService) { + public static ReservationPriceCalculator from(TicketReservation reservation, PromoCodeDiscount discount, List tickets, PurchaseContext purchaseContext, List>> additionalServiceItemsByAdditionalService, + List subscriptions, Optional appliedSubscription) { var additionalServiceItems = additionalServiceItemsByAdditionalService.stream().flatMap(p -> p.getRight().stream()).collect(Collectors.toList()); var additionalServices = additionalServiceItemsByAdditionalService.stream().map(Pair::getKey).collect(Collectors.toList()); - return new ReservationPriceCalculator(reservation, discount, tickets, additionalServiceItems, additionalServices, event); + return new ReservationPriceCalculator(reservation, discount, tickets, additionalServiceItems, additionalServices, purchaseContext, subscriptions, appliedSubscription); } } diff --git a/src/main/java/alfio/manager/system/SendGridMailer.java b/src/main/java/alfio/manager/system/SendGridMailer.java index bc5ef20ca6..947d708dad 100644 --- a/src/main/java/alfio/manager/system/SendGridMailer.java +++ b/src/main/java/alfio/manager/system/SendGridMailer.java @@ -16,7 +16,7 @@ */ package alfio.manager.system; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import alfio.model.system.ConfigurationKeys; import alfio.util.HttpUtils; import alfio.util.Json; @@ -44,8 +44,8 @@ public class SendGridMailer implements Mailer { private final ConfigurationManager configurationManager; @Override - public void send(final EventAndOrganizationId event, final String fromName, final String to, final List cc, final String subject, final String text, final Optional html, final Attachment... attachment) { - final var config = configurationManager.getFor(Set.of(ConfigurationKeys.SENDGRID_API_KEY, ConfigurationKeys.SENDGRID_FROM, ConfigurationKeys.MAIL_REPLY_TO), ConfigurationLevel.event(event)); + public void send(Configurable configurable, final String fromName, final String to, final List cc, final String subject, final String text, final Optional html, final Attachment... attachment) { + final var config = configurationManager.getFor(Set.of(ConfigurationKeys.SENDGRID_API_KEY, ConfigurationKeys.SENDGRID_FROM, ConfigurationKeys.MAIL_REPLY_TO), configurable.getConfigurationLevel()); final var from = config.get(ConfigurationKeys.SENDGRID_FROM).getRequiredValue(); final var personalizations = createPersonalizations(to, cc, subject); final var contents = createContents(text, html); diff --git a/src/main/java/alfio/manager/system/SmtpMailer.java b/src/main/java/alfio/manager/system/SmtpMailer.java index e4dac3fc78..32c72248db 100644 --- a/src/main/java/alfio/manager/system/SmtpMailer.java +++ b/src/main/java/alfio/manager/system/SmtpMailer.java @@ -16,7 +16,7 @@ */ package alfio.manager.system; -import alfio.model.EventAndOrganizationId; +import alfio.model.Configurable; import alfio.model.system.ConfigurationKeys; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -49,12 +49,12 @@ class SmtpMailer implements Mailer { private final ConfigurationManager configurationManager; @Override - public void send(EventAndOrganizationId event, String fromName, String to, List cc, String subject, String text, + public void send(Configurable configurable, String fromName, String to, List cc, String subject, String text, Optional html, Attachment... attachments) { var conf = configurationManager.getFor(Set.of(SMTP_FROM_EMAIL, MAIL_REPLY_TO, SMTP_HOST, SMTP_PORT, SMTP_PROTOCOL, - SMTP_USERNAME, SMTP_PASSWORD, SMTP_PROPERTIES), ConfigurationLevel.event(event)); + SMTP_USERNAME, SMTP_PASSWORD, SMTP_PROPERTIES), configurable.getConfigurationLevel()); MimeMessagePreparator preparator = mimeMessage -> { diff --git a/src/main/java/alfio/model/AllocationStatus.java b/src/main/java/alfio/model/AllocationStatus.java new file mode 100644 index 0000000000..bfdd9742a1 --- /dev/null +++ b/src/main/java/alfio/model/AllocationStatus.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.model; + +public enum AllocationStatus { + FREE, PRE_RESERVED, PENDING, TO_BE_PAID, ACQUIRED, CANCELLED, + CHECKED_IN, EXPIRED, + INVALIDATED, RELEASED; +} diff --git a/src/main/java/alfio/model/Audit.java b/src/main/java/alfio/model/Audit.java index 1fc862f442..b3c61c121d 100644 --- a/src/main/java/alfio/model/Audit.java +++ b/src/main/java/alfio/model/Audit.java @@ -29,7 +29,7 @@ public class Audit { public enum EntityType { - EVENT, TICKET, RESERVATION + EVENT, TICKET, RESERVATION, SUBSCRIPTION } public enum EventType { @@ -74,7 +74,8 @@ public enum EventType { MATCHING_PAYMENT_DISCARDED, AUTOMATIC_PAYMENT_CONFIRMATION, AUTOMATIC_PAYMENT_CONFIRMATION_FAILED, - DYNAMIC_DISCOUNT_CODE_CREATED + DYNAMIC_DISCOUNT_CODE_CREATED, + SUBSCRIPTION_ACQUIRED } diff --git a/src/main/java/alfio/model/BillingDocument.java b/src/main/java/alfio/model/BillingDocument.java index 9e1d429f3d..3627635836 100644 --- a/src/main/java/alfio/model/BillingDocument.java +++ b/src/main/java/alfio/model/BillingDocument.java @@ -36,7 +36,7 @@ public enum Status { } private final long id; - private final int eventId; + private final Integer eventId; private final String number; private final String reservationId; private final Type type; @@ -46,7 +46,7 @@ public enum Status { private final String externalId; public BillingDocument(@Column("id") long id, - @Column("event_id_fk") int eventId, + @Column("event_id_fk") Integer eventId, @Column("reservation_id_fk") String reservationId, @Column("number") String number, @Column("type") Type type, diff --git a/src/main/java/alfio/model/Configurable.java b/src/main/java/alfio/model/Configurable.java new file mode 100644 index 0000000000..e6a5186e4a --- /dev/null +++ b/src/main/java/alfio/model/Configurable.java @@ -0,0 +1,29 @@ +/** + * 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; + +import alfio.manager.system.ConfigurationLevel; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Represent a resource which have a configuration path + */ +public interface Configurable { + + @JsonIgnore + ConfigurationLevel getConfigurationLevel(); +} diff --git a/src/main/java/alfio/model/EmailMessage.java b/src/main/java/alfio/model/EmailMessage.java index 6db6fb9c36..3601ae3cdb 100644 --- a/src/main/java/alfio/model/EmailMessage.java +++ b/src/main/java/alfio/model/EmailMessage.java @@ -16,6 +16,7 @@ */ package alfio.model; +import alfio.model.PurchaseContext.PurchaseContextType; import alfio.util.Json; import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; import com.google.gson.reflect.TypeToken; @@ -28,6 +29,7 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Getter public class EmailMessage implements Comparable { @@ -37,7 +39,8 @@ public enum Status { } private final int id; - private final int eventId; + private final Integer eventId; + private final UUID subscriptionDescriptorId; private final Status status; private final String recipient; private final List cc; @@ -49,9 +52,11 @@ public enum Status { private final ZonedDateTime requestTimestamp; private final ZonedDateTime sentTimestamp; private final int attempts; + private final int organizationId; public EmailMessage(@Column("id") int id, - @Column("event_id") int eventId, + @Column("event_id") Integer eventId, + @Column("subscription_descriptor_id_fk") UUID subscriptionDescriptorId, @Column("status") String status, @Column("recipient") String recipient, @Column("subject") String subject, @@ -62,9 +67,11 @@ public EmailMessage(@Column("id") int id, @Column("request_ts") ZonedDateTime requestTimestamp, @Column("sent_ts") ZonedDateTime sentTimestamp, @Column("attempts") int attempts, - @Column("email_cc") String emailCC) { + @Column("email_cc") String emailCC, + @Column("organization_id_fk") int organizationId) { this.id = id; this.eventId = eventId; + this.subscriptionDescriptorId = subscriptionDescriptorId; this.requestTimestamp = requestTimestamp; this.sentTimestamp = sentTimestamp; this.status = Status.valueOf(status); @@ -75,6 +82,7 @@ public EmailMessage(@Column("id") int id, this.attachments = attachments; this.checksum = checksum; this.attempts = attempts; + this.organizationId = organizationId; if(StringUtils.isNotBlank(emailCC)) { this.cc = Json.GSON.fromJson(emailCC, new TypeToken>(){}.getType()); @@ -83,9 +91,17 @@ public EmailMessage(@Column("id") int id, } } + public PurchaseContextType getPurchaseContextType() { + return eventId != null ? PurchaseContextType.event : PurchaseContextType.subscription; + } + @Override public int compareTo(EmailMessage o) { - return new CompareToBuilder().append(eventId, o.eventId).append(checksum, o.checksum).build(); + return new CompareToBuilder() + .append(eventId, o.eventId) + .append(subscriptionDescriptorId, o.subscriptionDescriptorId) + .append(checksum, o.checksum) + .build(); } @Override @@ -97,11 +113,15 @@ public boolean equals(Object obj) { return true; } EmailMessage other = (EmailMessage)obj; - return new EqualsBuilder().append(eventId, other.eventId).append(checksum, other.checksum).isEquals(); + return new EqualsBuilder() + .append(eventId, other.eventId) + .append(subscriptionDescriptorId, other.subscriptionDescriptorId) + .append(checksum, other.checksum) + .isEquals(); } @Override public int hashCode() { - return new HashCodeBuilder().append(eventId).append(checksum).toHashCode(); + return new HashCodeBuilder().append(eventId).append(subscriptionDescriptorId).append(checksum).toHashCode(); } } diff --git a/src/main/java/alfio/model/Event.java b/src/main/java/alfio/model/Event.java index 98f45fef7c..2f8c464af5 100644 --- a/src/main/java/alfio/model/Event.java +++ b/src/main/java/alfio/model/Event.java @@ -31,17 +31,14 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.TimeZone; +import java.util.*; import java.util.stream.Collectors; import static java.time.temporal.ChronoField.OFFSET_SECONDS; @Getter @Log4j2 -public class Event extends EventAndOrganizationId implements EventHiddenFieldContainer, EventCheckInInfo, EventTimeZoneInfo { +public class Event extends EventAndOrganizationId implements EventHiddenFieldContainer, EventCheckInInfo, PurchaseContext { private static final String VERSION_FOR_FIRST_AND_LAST_NAME = "15.1.8.8"; @@ -146,6 +143,16 @@ public Event(@Column("id") int id, this.status = status; } + @Override + public Map getTitle() { + return buildTitle(displayName, locales); + } + + static Map buildTitle(String displayName, int locales) { + return ContentLanguage.findAllFor(locales).stream() + .collect(Collectors.toMap(cl -> cl.getLocale().getLanguage(), cl -> displayName)); + } + public BigDecimal getRegularPrice() { return MonetaryUtil.centsToUnit(srcPriceCts, currency); } @@ -197,6 +204,7 @@ public ZoneId getZoneId() { return timeZone; } + @Override public boolean isFreeOfCharge() { return srcPriceCts == 0; } @@ -275,4 +283,20 @@ public int getEndTimeZoneOffset() { public boolean getIsOnline() { return format == EventFormat.ONLINE; } + + @Override + public String getPublicIdentifier() { + return getShortName(); + } + + @Override + public PurchaseContextType getType() { + return PurchaseContextType.event; + } + + @Override + @JsonIgnore + public Optional event() { + return Optional.of(this); + } } diff --git a/src/main/java/alfio/model/EventAndOrganizationId.java b/src/main/java/alfio/model/EventAndOrganizationId.java index 9868d16c93..5827a1f640 100644 --- a/src/main/java/alfio/model/EventAndOrganizationId.java +++ b/src/main/java/alfio/model/EventAndOrganizationId.java @@ -16,11 +16,13 @@ */ package alfio.model; +import alfio.manager.system.ConfigurationLevel; import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; @Getter -public class EventAndOrganizationId { +public class EventAndOrganizationId implements Configurable { protected final int id; protected final int organizationId; @@ -29,4 +31,10 @@ public EventAndOrganizationId(@Column("id") int id, this.id = id; this.organizationId = organizationId; } + + @JsonIgnore + @Override + public ConfigurationLevel getConfigurationLevel() { + return ConfigurationLevel.event(this); + } } diff --git a/src/main/java/alfio/model/EventCheckInInfo.java b/src/main/java/alfio/model/EventCheckInInfo.java index 6ede7cf540..4c457386b4 100644 --- a/src/main/java/alfio/model/EventCheckInInfo.java +++ b/src/main/java/alfio/model/EventCheckInInfo.java @@ -18,7 +18,7 @@ import java.time.ZonedDateTime; -public interface EventCheckInInfo extends EventTimeZoneInfo { +public interface EventCheckInInfo extends TimeZoneInfo { int getId(); String getPrivateKey(); diff --git a/src/main/java/alfio/model/EventHiddenFieldContainer.java b/src/main/java/alfio/model/EventHiddenFieldContainer.java index 2829f46308..4004194c0c 100644 --- a/src/main/java/alfio/model/EventHiddenFieldContainer.java +++ b/src/main/java/alfio/model/EventHiddenFieldContainer.java @@ -16,6 +16,7 @@ */ package alfio.model; +import alfio.manager.system.ConfigurationLevel; import com.fasterxml.jackson.annotation.JsonIgnore; import org.apache.commons.lang3.tuple.Pair; @@ -34,4 +35,7 @@ public interface EventHiddenFieldContainer { @JsonIgnore BigDecimal getVat(); + + @JsonIgnore + ConfigurationLevel getConfigurationLevel(); } diff --git a/src/main/java/alfio/model/EventWithAdditionalInfo.java b/src/main/java/alfio/model/EventWithAdditionalInfo.java index 9bb0c63e39..dcea4dcef8 100644 --- a/src/main/java/alfio/model/EventWithAdditionalInfo.java +++ b/src/main/java/alfio/model/EventWithAdditionalInfo.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; @AllArgsConstructor @Getter @@ -46,6 +47,7 @@ public class EventWithAdditionalInfo implements StatisticsContainer, PriceContai private final BigDecimal grossIncome; private final AlfioMetadata metadata; + private final List linkedSubscriptions; @JsonIgnore public Event getEvent() { diff --git a/src/main/java/alfio/model/LightweightMailMessage.java b/src/main/java/alfio/model/LightweightMailMessage.java index 935de81970..c7971d9ff4 100644 --- a/src/main/java/alfio/model/LightweightMailMessage.java +++ b/src/main/java/alfio/model/LightweightMailMessage.java @@ -19,10 +19,12 @@ import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; import java.time.ZonedDateTime; +import java.util.UUID; public class LightweightMailMessage extends EmailMessage { public LightweightMailMessage(@Column("id") int id, - @Column("event_id") int eventId, + @Column("event_id") Integer eventId, + @Column("subscription_descriptor_id_fk") UUID subscriptionDescriptorId, @Column("status") String status, @Column("recipient") String recipient, @Column("subject") String subject, @@ -31,7 +33,22 @@ public LightweightMailMessage(@Column("id") int id, @Column("request_ts") ZonedDateTime requestTimestamp, @Column("sent_ts") ZonedDateTime sentTimestamp, @Column("attempts") int attempts, - @Column("email_cc") String cc) { - super(id, eventId, status, recipient, subject, message, null, null, checksum, requestTimestamp, sentTimestamp, attempts, cc); + @Column("email_cc") String cc, + @Column("organization_id_fk") int organizationId) { + super(id, + eventId, + subscriptionDescriptorId, + status, + recipient, + subject, + message, + null, + null, + checksum, + requestTimestamp, + sentTimestamp, + attempts, + cc, + organizationId); } } diff --git a/src/main/java/alfio/model/LocalizedContent.java b/src/main/java/alfio/model/LocalizedContent.java new file mode 100644 index 0000000000..246eaa7fec --- /dev/null +++ b/src/main/java/alfio/model/LocalizedContent.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.model; + +import java.util.List; + +public interface LocalizedContent { + List getContentLanguages(); +} diff --git a/src/main/java/alfio/model/PriceContainer.java b/src/main/java/alfio/model/PriceContainer.java index f1e9f718e0..1b006e3b0d 100644 --- a/src/main/java/alfio/model/PriceContainer.java +++ b/src/main/java/alfio/model/PriceContainer.java @@ -60,6 +60,10 @@ public BigDecimal extractRawVAT(BigDecimal price, BigDecimal vatPercentage) { public static boolean isVatExempt(VatStatus vatStatus) { return vatStatus == INCLUDED_EXEMPT || vatStatus == NOT_INCLUDED_EXEMPT; } + + public static boolean isVatIncluded(VatStatus vatStatus) { + return vatStatus == INCLUDED || vatStatus == INCLUDED_EXEMPT; + } } /** diff --git a/src/main/java/alfio/model/PurchaseContext.java b/src/main/java/alfio/model/PurchaseContext.java new file mode 100644 index 0000000000..289cbbb91f --- /dev/null +++ b/src/main/java/alfio/model/PurchaseContext.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; + +import alfio.model.transaction.PaymentProxy; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface PurchaseContext extends Configurable, TimeZoneInfo, LocalizedContent { + + Map getTitle(); + + BigDecimal getVat(); + PriceContainer.VatStatus getVatStatus(); + String getCurrency(); + + List getAllowedPaymentProxies(); + String getPrivacyPolicyLinkOrNull(); + String getPrivacyPolicyUrl(); + String getTermsAndConditionsUrl(); + + // + int getOrganizationId(); + + // + PurchaseContextType getType(); + String getPublicIdentifier(); + + String getFileBlobId(); + + boolean isFreeOfCharge(); + + + String getDisplayName(); + + //FIXME: check every USE + Optional event(); + + ZonedDateTime getBegin(); + + + enum PurchaseContextType { + subscription("subscription"), event("event"); + + private final String urlComponent; + + PurchaseContextType(String urlComponent) { + this.urlComponent = urlComponent; + } + + public static PurchaseContextType from(String purchaseContextType) { + switch (purchaseContextType) { + case "subscription": return subscription; + case "event": return event; + default: throw new IllegalStateException("Purchase type not supported:" + purchaseContextType); + } + } + + public String getUrlComponent() { + return urlComponent; + } + } + + String getPrivateKey(); + + default boolean mustUseFirstAndLastName() { + return true; + } + + default boolean getFileBlobIdIsPresent() { + return true; + } +} diff --git a/src/main/java/alfio/model/SummaryRow.java b/src/main/java/alfio/model/SummaryRow.java index 0aedafbcdf..5bda280eb1 100644 --- a/src/main/java/alfio/model/SummaryRow.java +++ b/src/main/java/alfio/model/SummaryRow.java @@ -30,7 +30,7 @@ public class SummaryRow { private final SummaryType type; public enum SummaryType { - TICKET, PROMOTION_CODE, DYNAMIC_DISCOUNT, ADDITIONAL_SERVICE + TICKET, PROMOTION_CODE, DYNAMIC_DISCOUNT, ADDITIONAL_SERVICE, SUBSCRIPTION } public String getDescriptionForPayment() { diff --git a/src/main/java/alfio/model/TicketReservation.java b/src/main/java/alfio/model/TicketReservation.java index 7427fcc526..0cd28ac76f 100644 --- a/src/main/java/alfio/model/TicketReservation.java +++ b/src/main/java/alfio/model/TicketReservation.java @@ -17,7 +17,6 @@ package alfio.model; import alfio.model.transaction.PaymentProxy; -import alfio.util.Json; import alfio.util.MonetaryUtil; import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; import com.fasterxml.jackson.annotation.JsonIgnore; diff --git a/src/main/java/alfio/model/EventTimeZoneInfo.java b/src/main/java/alfio/model/TimeZoneInfo.java similarity index 97% rename from src/main/java/alfio/model/EventTimeZoneInfo.java rename to src/main/java/alfio/model/TimeZoneInfo.java index de7eaf7460..34e079ebf7 100644 --- a/src/main/java/alfio/model/EventTimeZoneInfo.java +++ b/src/main/java/alfio/model/TimeZoneInfo.java @@ -22,7 +22,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; -interface EventTimeZoneInfo { +interface TimeZoneInfo { ZoneId getZoneId(); diff --git a/src/main/java/alfio/model/api/v1/admin/EventCreationRequest.java b/src/main/java/alfio/model/api/v1/admin/EventCreationRequest.java index 81882823b5..205ab18ae8 100644 --- a/src/main/java/alfio/model/api/v1/admin/EventCreationRequest.java +++ b/src/main/java/alfio/model/api/v1/admin/EventCreationRequest.java @@ -103,7 +103,8 @@ public EventModification toEventModification(Organization organization, UnaryOpe locales, toAdditionalFields(orEmpty(additionalInfo)), emptyList(), // TODO improve API, - AlfioMetadata.empty() + AlfioMetadata.empty(), + List.of() ); } @@ -155,7 +156,8 @@ public EventModification toEventModificationUpdate(EventWithAdditionalInfo origi locales, toAdditionalFields(orEmpty(additionalInfo)), emptyList(), // TODO improve API - AlfioMetadata.empty() + AlfioMetadata.empty(), + List.of() ); } diff --git a/src/main/java/alfio/model/decorator/AdditionalServiceItemPriceContainer.java b/src/main/java/alfio/model/decorator/AdditionalServiceItemPriceContainer.java index 4c40940617..cf4c8d774d 100644 --- a/src/main/java/alfio/model/decorator/AdditionalServiceItemPriceContainer.java +++ b/src/main/java/alfio/model/decorator/AdditionalServiceItemPriceContainer.java @@ -76,9 +76,9 @@ public BigDecimal getTaxablePrice() { return MonetaryUtil.centsToUnit(getSrcPriceCts(), getCurrencyCode()); } - public static AdditionalServiceItemPriceContainer from(AdditionalServiceItem item, AdditionalService additionalService, Event event, PromoCodeDiscount discount) { + public static AdditionalServiceItemPriceContainer from(AdditionalServiceItem item, AdditionalService additionalService, PurchaseContext purchaseContext, PromoCodeDiscount discount) { var discountToApply = isDiscountCompatible(discount) && additionalService.getType() != AdditionalService.AdditionalServiceType.DONATION ? discount : null; - return new AdditionalServiceItemPriceContainer(item, additionalService, event.getCurrency(), discountToApply, event.getVatStatus(), event.getVat()); + return new AdditionalServiceItemPriceContainer(item, additionalService, purchaseContext.getCurrency(), discountToApply, purchaseContext.getVatStatus(), purchaseContext.getVat()); } private static boolean isDiscountCompatible(PromoCodeDiscount discount) { diff --git a/src/main/java/alfio/model/decorator/SubscriptionPriceContainer.java b/src/main/java/alfio/model/decorator/SubscriptionPriceContainer.java new file mode 100644 index 0000000000..9dd9e6da43 --- /dev/null +++ b/src/main/java/alfio/model/decorator/SubscriptionPriceContainer.java @@ -0,0 +1,63 @@ +/** + * 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.decorator; + +import alfio.model.PriceContainer; +import alfio.model.PromoCodeDiscount; +import alfio.model.PurchaseContext; +import alfio.model.subscription.Subscription; + +import java.math.BigDecimal; +import java.util.Optional; + +public class SubscriptionPriceContainer implements PriceContainer { + + + private final Subscription subscription; + private final PurchaseContext purchaseContext; + private final PromoCodeDiscount promoCodeDiscount; + + public SubscriptionPriceContainer(Subscription s, PurchaseContext purchaseContext, PromoCodeDiscount discount) { + this.subscription = s; + this.purchaseContext = purchaseContext; + this.promoCodeDiscount = discount; + } + + public static PriceContainer from(Subscription s, PurchaseContext purchaseContext, PromoCodeDiscount discount) { + return new SubscriptionPriceContainer(s, purchaseContext, discount); + } + + @Override + public int getSrcPriceCts() { + return subscription.getSrcPriceCts(); + } + + @Override + public String getCurrencyCode() { + return subscription.getCurrency(); + } + + @Override + public Optional getOptionalVatPercentage() { + return Optional.ofNullable(purchaseContext.getVat()); + } + + @Override + public VatStatus getVatStatus() { + return purchaseContext.getVatStatus(); + } +} diff --git a/src/main/java/alfio/model/metadata/AlfioMetadata.java b/src/main/java/alfio/model/metadata/AlfioMetadata.java index 93ba70f032..1b5c3adb99 100644 --- a/src/main/java/alfio/model/metadata/AlfioMetadata.java +++ b/src/main/java/alfio/model/metadata/AlfioMetadata.java @@ -25,24 +25,22 @@ @Getter public class AlfioMetadata { - private final List tags; private final OnlineConfiguration onlineConfiguration; // list of requirements for participants, e.g. software private final Map requirementsDescriptions; private final List conditionsToBeAccepted; @JsonCreator - public AlfioMetadata(@JsonProperty("tags") List tags, + public AlfioMetadata( @JsonProperty("onlineConfiguration") OnlineConfiguration onlineConfiguration, @JsonProperty("requirementsDescriptions") Map requirementsDescriptions, @JsonProperty("conditionsToBeAccepted") List conditionsToBeAccepted) { - this.tags = tags; this.onlineConfiguration = onlineConfiguration; this.requirementsDescriptions = requirementsDescriptions; this.conditionsToBeAccepted = conditionsToBeAccepted; } public static AlfioMetadata empty() { - return new AlfioMetadata(List.of(), null, Map.of(), List.of()); + return new AlfioMetadata(null, Map.of(), List.of()); } } diff --git a/src/main/java/alfio/model/modification/EventModification.java b/src/main/java/alfio/model/modification/EventModification.java index a6822acc0d..b85949395f 100644 --- a/src/main/java/alfio/model/modification/EventModification.java +++ b/src/main/java/alfio/model/modification/EventModification.java @@ -70,6 +70,8 @@ public class EventModification { private final AlfioMetadata metadata; + private final List linkedSubscriptions; + @JsonCreator public EventModification(@JsonProperty("id") Integer id, @JsonProperty("format") Event.EventFormat format, @@ -101,7 +103,8 @@ public EventModification(@JsonProperty("id") Integer id, @JsonProperty("locales") int locales, @JsonProperty("ticketFields") List ticketFields, @JsonProperty("additionalServices") List additionalServices, - @JsonProperty("metadata") AlfioMetadata metadata) { + @JsonProperty("metadata") AlfioMetadata metadata, + @JsonProperty("linkedSubscriptions") List linkedSubscriptions) { this.id = id; this.format = format; this.websiteUrl = websiteUrl; @@ -133,6 +136,7 @@ public EventModification(@JsonProperty("id") Integer id, this.locales = locales; this.ticketFields = ticketFields; this.metadata = metadata; + this.linkedSubscriptions = linkedSubscriptions; } public int getPriceInCents() { diff --git a/src/main/java/alfio/model/modification/MetadataModification.java b/src/main/java/alfio/model/modification/MetadataModification.java index edefed53ea..f15c01a204 100644 --- a/src/main/java/alfio/model/modification/MetadataModification.java +++ b/src/main/java/alfio/model/modification/MetadataModification.java @@ -46,7 +46,6 @@ public boolean isValid() { public AlfioMetadata toMetadataObj() { return new AlfioMetadata( - List.of(), new OnlineConfiguration(callLinks.stream().map(CallLinkModification::toCallLink).collect(Collectors.toList())), requirementsDescriptions, List.of() diff --git a/src/main/java/alfio/model/modification/SubscriptionDescriptorModification.java b/src/main/java/alfio/model/modification/SubscriptionDescriptorModification.java new file mode 100644 index 0000000000..f55a45a942 --- /dev/null +++ b/src/main/java/alfio/model/modification/SubscriptionDescriptorModification.java @@ -0,0 +1,166 @@ +/** + * 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.modification; + +import alfio.model.PriceContainer.VatStatus; +import alfio.model.subscription.SubscriptionDescriptor; +import alfio.model.subscription.SubscriptionDescriptor.SubscriptionTimeUnit; +import alfio.model.subscription.SubscriptionDescriptor.SubscriptionUsageType; +import alfio.model.subscription.SubscriptionDescriptor.SubscriptionValidityType; +import alfio.model.transaction.PaymentProxy; +import alfio.util.MonetaryUtil; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Getter +public class SubscriptionDescriptorModification { + private final UUID id; + private final Map title; + private final Map description; + private final Integer maxAvailable; + private final ZonedDateTime onSaleFrom; + private final ZonedDateTime onSaleTo; + private final BigDecimal price; + private final BigDecimal vat; + private final VatStatus vatStatus; + private final String currency; + private final Boolean isPublic; + private final int organizationId; + + private final Integer maxEntries; + private final SubscriptionValidityType validityType; + private final SubscriptionTimeUnit validityTimeUnit; + private final Integer validityUnits; + private final ZonedDateTime validityFrom; + private final ZonedDateTime validityTo; + private final SubscriptionUsageType usageType; + + private final String termsAndConditionsUrl; + private final String privacyPolicyUrl; + private final String fileBlobId; + private final List paymentProxies; + private final ZoneId timeZone; + + public SubscriptionDescriptorModification(@JsonProperty("id") UUID id, + @JsonProperty("title") Map title, + @JsonProperty("description") Map description, + @JsonProperty("maxAvailable") Integer maxAvailable, + @JsonProperty("onSaleFrom") ZonedDateTime onSaleFrom, + @JsonProperty("onSaleTo") ZonedDateTime onSaleTo, + @JsonProperty("price") BigDecimal price, + @JsonProperty("vat") BigDecimal vat, + @JsonProperty("vatStatus") VatStatus vatStatus, + @JsonProperty("currency") String currency, + @JsonProperty("isPublic") Boolean isPublic, + @JsonProperty("organizationId") int organizationId, + @JsonProperty("maxEntries") Integer maxEntries, + @JsonProperty("validityType") SubscriptionValidityType validityType, + @JsonProperty("validityTimeUnit") SubscriptionTimeUnit validityTimeUnit, + @JsonProperty("validityUnits") Integer validityUnits, + @JsonProperty("validityFrom") ZonedDateTime validityFrom, + @JsonProperty("validityTo") ZonedDateTime validityTo, + @JsonProperty("usageType") SubscriptionUsageType usageType, + @JsonProperty("termsAndConditionsUrl") String termsAndConditionsUrl, + @JsonProperty("privacyPolicyUrl") String privacyPolicyUrl, + @JsonProperty("fileBlobId") String fileBlobId, + @JsonProperty("paymentProxies") List paymentProxies, + @JsonProperty("timeZone") ZoneId timeZone) { + this.id = id; + this.title = title; + this.description = description; + this.maxAvailable = maxAvailable; + this.onSaleFrom = onSaleFrom; + this.onSaleTo = onSaleTo; + this.price = price; + this.vat = vat; + this.vatStatus = vatStatus; + this.currency = currency; + this.isPublic = isPublic; + this.organizationId = organizationId; + this.maxEntries = maxEntries; + this.validityType = validityType; + this.validityTimeUnit = validityTimeUnit; + this.validityUnits = validityUnits; + this.validityFrom = validityFrom; + this.validityTo = validityTo; + this.usageType = usageType; + this.termsAndConditionsUrl = termsAndConditionsUrl; + this.privacyPolicyUrl = privacyPolicyUrl; + this.fileBlobId = fileBlobId; + this.paymentProxies = paymentProxies; + this.timeZone = timeZone; + } + + public int getPriceCts() { + return MonetaryUtil.unitToCents(price, currency); + } + + public DateTimeModification getValidityFromModel() { + return DateTimeModification.fromZonedDateTime(validityFrom); + } + + public DateTimeModification getValidityToModel() { + return DateTimeModification.fromZonedDateTime(validityTo); + } + + public DateTimeModification getOnSaleFromModel() { + return DateTimeModification.fromZonedDateTime(onSaleFrom); + } + + public DateTimeModification getOnSaleToModel() { + return DateTimeModification.fromZonedDateTime(onSaleTo); + } + + public String getPublicIdentifier() { + return getId().toString(); + } + + public static SubscriptionDescriptorModification fromModel(SubscriptionDescriptor subscriptionDescriptor) { + return new SubscriptionDescriptorModification( + subscriptionDescriptor.getId(), + subscriptionDescriptor.getTitle(), + subscriptionDescriptor.getDescription(), + subscriptionDescriptor.getMaxAvailable(), + subscriptionDescriptor.getOnSaleFrom(), + subscriptionDescriptor.getOnSaleTo(), + MonetaryUtil.centsToUnit(subscriptionDescriptor.getPrice(), subscriptionDescriptor.getCurrency()), + subscriptionDescriptor.getVat(), + subscriptionDescriptor.getVatStatus(), + subscriptionDescriptor.getCurrency(), + subscriptionDescriptor.isPublic(), + subscriptionDescriptor.getOrganizationId(), + subscriptionDescriptor.getMaxEntries(), + subscriptionDescriptor.getValidityType(), + subscriptionDescriptor.getValidityTimeUnit(), + subscriptionDescriptor.getValidityUnits(), + subscriptionDescriptor.getValidityFrom(), + subscriptionDescriptor.getValidityTo(), + subscriptionDescriptor.getUsageType(), + subscriptionDescriptor.getTermsAndConditionsUrl(), + subscriptionDescriptor.getPrivacyPolicyUrl(), + subscriptionDescriptor.getFileBlobId(), + subscriptionDescriptor.getPaymentProxies(), + subscriptionDescriptor.getZoneId()); + } +} diff --git a/src/main/java/alfio/model/subscription/EventSubscriptionLink.java b/src/main/java/alfio/model/subscription/EventSubscriptionLink.java new file mode 100644 index 0000000000..a1fb660506 --- /dev/null +++ b/src/main/java/alfio/model/subscription/EventSubscriptionLink.java @@ -0,0 +1,58 @@ +/** + * 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 alfio.model.support.JSONData; +import alfio.util.MonetaryUtil; +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +@Getter +public class EventSubscriptionLink { + private final UUID subscriptionDescriptorId; + private final Map subscriptionDescriptorTitle; + private final int eventId; + private final String eventShortName; + private final String eventDisplayName; + private final int pricePerTicket; + private final String eventCurrency; + + + public EventSubscriptionLink(@Column("subscription_descriptor_id") UUID subscriptionDescriptorId, + @Column("subscription_descriptor_title") @JSONData Map subscriptionDescriptorTitle, + @Column("event_id") int eventId, + @Column("event_short_name") String eventShortName, + @Column("event_display_name") String eventDisplayName, + @Column("event_currency") String eventCurrency, + @Column("price_per_ticket") int pricePerTicket) { + this.subscriptionDescriptorId = subscriptionDescriptorId; + this.subscriptionDescriptorTitle = subscriptionDescriptorTitle; + this.eventId = eventId; + this.eventShortName = eventShortName; + this.eventCurrency = eventCurrency; + this.eventDisplayName = eventDisplayName; + this.pricePerTicket = pricePerTicket; + } + + public BigDecimal getFormattedPricePerTicket() { + return MonetaryUtil.centsToUnit(pricePerTicket, eventCurrency); + } +} diff --git a/src/main/java/alfio/model/subscription/Subscription.java b/src/main/java/alfio/model/subscription/Subscription.java new file mode 100644 index 0000000000..b69d37fb8a --- /dev/null +++ b/src/main/java/alfio/model/subscription/Subscription.java @@ -0,0 +1,108 @@ +/** + * 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 alfio.model.AllocationStatus; +import alfio.util.PinGenerator; +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; +import org.springframework.validation.BindingResult; + +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.UUID; + +@Getter +public class Subscription { + + private final UUID id; + private final String firstName; + private final String lastName; + private final String email; + private final UUID subscriptionDescriptorId; + private final String reservationId; + private final int usageCount; + private final int organizationId; + private final ZonedDateTime creationTime; + private final ZonedDateTime updateTime; + private final int srcPriceCts; + private final int discountCts; + private final String currency; + private final AllocationStatus status; + + public static final int PIN_LENGTH = 8; + + + public Subscription(@Column("id") UUID id, + @Column("first_name") String firstName, + @Column("last_name") String lastName, + @Column("email_address") String email, + @Column("subscription_descriptor_fk") UUID subscriptionDescriptorId, + @Column("reservation_id_fk") String reservationId, + @Column("usage_count") int usageCount, + @Column("organization_id_fk") int organizationId, + @Column("creation_ts") ZonedDateTime creationTime, + @Column("update_ts") ZonedDateTime updateTime, + @Column("src_price_cts") int srcPriceCts, + @Column("discount_cts") int discountCts, + @Column("currency") String currency, + @Column("status") AllocationStatus status) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.subscriptionDescriptorId = subscriptionDescriptorId; + this.reservationId = reservationId; + this.usageCount = usageCount; + this.organizationId = organizationId; + this.creationTime = creationTime; + this.updateTime = updateTime; + this.srcPriceCts = srcPriceCts; + this.discountCts = discountCts; + this.currency = currency; + this.status = status; + } + + public boolean isValid(SubscriptionDescriptor subscriptionDescriptor, Optional bindingResult) { + if (status != AllocationStatus.ACQUIRED) { + reject(bindingResult, "subscription.not.acquired"); + return false; + } + if (!subscriptionDescriptor.isUnlimitedAccess() && this.usageCount >= subscriptionDescriptor.getMaxEntries()) { + reject(bindingResult, "subscription.max-usage-reached"); + return false; + } + //FIXME implement validation rules: + // date -> can be validFrom/validTo + // -> can be valid X {day, weeks, months} from the acquired date + // usage check: once or more per event + // max available -> this we need to pre-generate + return true; + } + + private static void reject(Optional bindingResult, String errorCode) { + bindingResult.ifPresent(b -> b.reject(errorCode)); + } + + public boolean isValid(SubscriptionDescriptor subscriptionDescriptor) { + return isValid(subscriptionDescriptor, Optional.empty()); + } + + public String getPin() { + return PinGenerator.uuidToPin(id.toString(), PIN_LENGTH); + } +} diff --git a/src/main/java/alfio/model/subscription/SubscriptionDescriptor.java b/src/main/java/alfio/model/subscription/SubscriptionDescriptor.java new file mode 100644 index 0000000000..4824c7f84b --- /dev/null +++ b/src/main/java/alfio/model/subscription/SubscriptionDescriptor.java @@ -0,0 +1,242 @@ +/** + * 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 alfio.manager.system.ConfigurationLevel; +import alfio.model.ContentLanguage; +import alfio.model.Event; +import alfio.model.PriceContainer.VatStatus; +import alfio.model.PurchaseContext; +import alfio.model.support.Array; +import alfio.model.support.JSONData; +import alfio.model.transaction.PaymentProxy; +import alfio.util.ClockProvider; +import alfio.util.MustacheCustomTag; +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +import java.math.BigDecimal; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Getter +public class SubscriptionDescriptor implements PurchaseContext { + + public enum SubscriptionUsageType { + ONCE_PER_EVENT, UNLIMITED + } + + public enum SubscriptionTimeUnit { + DAYS, MONTHS, YEARS + } + + public enum SubscriptionValidityType { + STANDARD, CUSTOM, NOT_SET + } + + private final UUID id; + private final Map title; + private final Map description; + private final int maxAvailable; + private final ZonedDateTime creation; + private final ZonedDateTime onSaleFrom; + private final ZonedDateTime onSaleTo; + private final int price; + private final BigDecimal vat; + private final VatStatus vatStatus; + private final String currency; + private final boolean isPublic; + private final int organizationId; + + private final int maxEntries; + private final SubscriptionValidityType validityType; + private final SubscriptionTimeUnit validityTimeUnit; + private final Integer validityUnits; + private final ZonedDateTime validityFrom; + private final ZonedDateTime validityTo; + private final SubscriptionUsageType usageType; + + private final String termsAndConditionsUrl; + private final String privacyPolicyUrl; + private final String fileBlobId; + private final List paymentProxies; + private final String privateKey; + private final String timeZone; + + public SubscriptionDescriptor(@Column("id") UUID id, + @Column("title") @JSONData Map title, + @Column("description") @JSONData Map description, + @Column("max_available") int maxAvailable, + @Column("creation_ts") ZonedDateTime creation, + @Column("on_sale_from") ZonedDateTime onSaleFrom, + @Column("on_sale_to") ZonedDateTime onSaleTo, + @Column("price_cts") int price, + @Column("vat") BigDecimal vat, + @Column("vat_status") VatStatus vatStatus, + @Column("currency") String currency, + @Column("is_public") boolean isPublic, + @Column("organization_id_fk") int organizationId, + + @Column("max_entries") int maxEntries, + @Column("validity_type") SubscriptionValidityType validityType, + @Column("validity_time_unit") SubscriptionTimeUnit validityTimeUnit, + @Column("validity_units") Integer validityUnits, + @Column("validity_from") ZonedDateTime validityFrom, + @Column("validity_to") ZonedDateTime validityTo, + @Column("usage_type") SubscriptionUsageType usageType, + + @Column("terms_conditions_url") String termsAndConditionsUrl, + @Column("privacy_policy_url") String privacyPolicyUrl, + @Column("file_blob_id_fk") String fileBlobId, + @Column("allowed_payment_proxies") @Array List paymentProxies, + @Column("private_key") String privateKey, + @Column("time_zone") String timeZone) { + var zoneId = ZoneId.of(timeZone); + this.id = id; + this.title = title; + this.description = description; + this.maxAvailable = maxAvailable; + this.creation = creation; + this.timeZone = timeZone; + this.onSaleFrom = atZone(onSaleFrom, zoneId); + this.onSaleTo = atZone(onSaleTo, zoneId); + this.price = price; + this.vat = vat; + this.vatStatus = vatStatus; + this.currency = currency; + this.isPublic = isPublic; + this.organizationId = organizationId; + + this.maxEntries = maxEntries; + this.validityType = validityType; + this.validityTimeUnit = validityTimeUnit; + this.validityUnits = validityUnits; + this.validityFrom = atZone(validityFrom, zoneId); + this.validityTo = atZone(validityTo, zoneId); + this.usageType = usageType; + + this.termsAndConditionsUrl = termsAndConditionsUrl; + this.privacyPolicyUrl = privacyPolicyUrl; + this.fileBlobId = fileBlobId; + this.paymentProxies = paymentProxies.stream().map(PaymentProxy::valueOf).collect(Collectors.toList()); + + this.privateKey = privateKey; + } + + @Override + public List getContentLanguages() { + var languages = title.keySet(); + return ContentLanguage.ALL_LANGUAGES.stream() + .filter(l -> languages.contains(l.getLanguage())) + .collect(Collectors.toList()); + } + + @JsonIgnore + @Override + public ConfigurationLevel getConfigurationLevel() { + return ConfigurationLevel.organization(organizationId); + } + + @Override + public List getAllowedPaymentProxies() { + return getPaymentProxies(); + } + + @Override + public String getPrivacyPolicyLinkOrNull() { + return StringUtils.trimToNull(privacyPolicyUrl); + } + + @Override + public String getPublicIdentifier() { + return getId().toString(); + } + + @JsonIgnore + @Override + public PurchaseContextType getType() { + return PurchaseContextType.subscription; + } + + @JsonIgnore + @Override + public ZoneId getZoneId() { + return ZoneId.of(timeZone); + } + + @Override + public String getDisplayName() { + return title.keySet().stream().findFirst().map(title::get).orElse("Subscription"); //FIXME + } + + @JsonIgnore + @Override + public Optional event() { + return Optional.empty(); + } + + @JsonIgnore + @Override + public String getPrivateKey() { + return privateKey; + } + + @Override + public ZonedDateTime getBegin() { + return validityFrom != null ? validityFrom : ZonedDateTime.now(ClockProvider.clock()).plusMonths(2); + } + + @JsonIgnore + public boolean isUnlimitedAccess() { + return maxEntries == -1; + } + + @Override + public boolean isFreeOfCharge() { + return false; + } + + private static ZonedDateTime atZone(ZonedDateTime in, ZoneId zone) { + if(in != null) { + return in.withZoneSameInstant(zone); + } + return null; + } + + public String getLocalizedTitle(Locale locale) { + var fallbackLocale = title.keySet().stream().findFirst().orElse("en"); + return MustacheCustomTag.renderToTextCommonmark(title.getOrDefault(locale.toLanguageTag(), fallbackLocale)); + } + + public Map getTitleAsText() { + return renderTextCommonMark(title); + } + + public Map getDescriptionAsText() { + return renderTextCommonMark(description); + } + + private Map renderTextCommonMark(Map original) { + return original.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), MustacheCustomTag.renderToTextCommonmark(entry.getValue()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} diff --git a/src/main/java/alfio/model/subscription/SubscriptionDescriptorWithStatistics.java b/src/main/java/alfio/model/subscription/SubscriptionDescriptorWithStatistics.java new file mode 100644 index 0000000000..c8b66889b9 --- /dev/null +++ b/src/main/java/alfio/model/subscription/SubscriptionDescriptorWithStatistics.java @@ -0,0 +1,110 @@ +/** + * 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 alfio.model.PriceContainer; +import alfio.model.support.Array; +import alfio.model.support.JSONData; +import alfio.util.MonetaryUtil; +import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Getter +public class SubscriptionDescriptorWithStatistics { + + private final int soldCount; + private final int linkedEventsCount; + private final int reservationsCount; + private final SubscriptionDescriptor descriptor; + + public SubscriptionDescriptorWithStatistics( + + @Column("sd_id") UUID id, + @Column("sd_title") @JSONData Map title, + @Column("sd_description") @JSONData Map description, + @Column("sd_max_available") int maxAvailable, + @Column("sd_creation_ts") ZonedDateTime creation, + @Column("sd_on_sale_from") ZonedDateTime onSaleFrom, + @Column("sd_on_sale_to") ZonedDateTime onSaleTo, + @Column("sd_price_cts") int price, + @Column("sd_vat") BigDecimal vat, + @Column("sd_vat_status") PriceContainer.VatStatus vatStatus, + @Column("sd_currency") String currency, + @Column("sd_is_public") boolean isPublic, + @Column("sd_organization_id_fk") int organizationId, + + @Column("sd_max_entries") int maxEntries, + @Column("sd_validity_type") SubscriptionDescriptor.SubscriptionValidityType validityType, + @Column("sd_validity_time_unit") SubscriptionDescriptor.SubscriptionTimeUnit validityTimeUnit, + @Column("sd_validity_units") Integer validityUnits, + @Column("sd_validity_from") ZonedDateTime validityFrom, + @Column("sd_validity_to") ZonedDateTime validityTo, + @Column("sd_usage_type") SubscriptionDescriptor.SubscriptionUsageType usageType, + + @Column("sd_terms_conditions_url") String termsAndConditionsUrl, + @Column("sd_privacy_policy_url") String privacyPolicyUrl, + @Column("sd_file_blob_id_fk") String fileBlobId, + @Column("sd_allowed_payment_proxies") @Array List paymentProxies, + @Column("sd_private_key") String privateKey, + @Column("sd_time_zone") String timeZone, + + + @Column("s_sold_count") int soldCount, + @Column("s_reservations_count") int reservationsCount, + @Column("s_events_count") int linkedEventsCount) { + + this.soldCount = soldCount; + this.linkedEventsCount = linkedEventsCount; + this.reservationsCount = reservationsCount; + this.descriptor = new SubscriptionDescriptor(id, + title, + description, + maxAvailable, + creation, + onSaleFrom, + onSaleTo, + price, + vat, + vatStatus, + currency, + isPublic, + organizationId, + maxEntries, + validityType, + validityTimeUnit, + validityUnits, + validityFrom, + validityTo, + usageType, + termsAndConditionsUrl, + privacyPolicyUrl, + fileBlobId, + paymentProxies, + privateKey, + timeZone); + } + + public BigDecimal getUnitPrice() { + return MonetaryUtil.centsToUnit(descriptor.getPrice(), descriptor.getCurrency()); + } +} diff --git a/src/main/java/alfio/model/subscription/SubscriptionPriceContainer.java b/src/main/java/alfio/model/subscription/SubscriptionPriceContainer.java new file mode 100644 index 0000000000..c693056806 --- /dev/null +++ b/src/main/java/alfio/model/subscription/SubscriptionPriceContainer.java @@ -0,0 +1,85 @@ +/** + * 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 alfio.model.PriceContainer; +import alfio.model.PromoCodeDiscount; +import lombok.AllArgsConstructor; + +import java.math.BigDecimal; +import java.util.Optional; + +import static alfio.util.MonetaryUtil.centsToUnit; +import static alfio.util.MonetaryUtil.unitToCents; + +@AllArgsConstructor +public class SubscriptionPriceContainer implements PriceContainer { + + private final Subscription subscription; + private final PromoCodeDiscount promoCodeDiscount; + private final SubscriptionDescriptor descriptor; + + + @Override + public int getSrcPriceCts() { + return subscription.getSrcPriceCts(); + } + + @Override + public String getCurrencyCode() { + return subscription.getCurrency(); + } + + @Override + public Optional getOptionalVatPercentage() { + return Optional.ofNullable(descriptor.getVat()); + } + + @Override + public VatStatus getVatStatus() { + return descriptor.getVatStatus(); + } + + @Override + public Optional getDiscount() { + return Optional.ofNullable(promoCodeDiscount); + } + + // FIXME remove once merged, as it has been implemented in Master + public BigDecimal getNetPrice() { + var vatStatus = getVatStatus(); + var currencyCode = getCurrencyCode(); + if(vatStatus == VatStatus.NOT_INCLUDED_EXEMPT) { + return centsToUnit(getSrcPriceCts(), currencyCode); + } else if(vatStatus == VatStatus.INCLUDED_EXEMPT) { + var rawVat = vatStatus.extractRawVAT(centsToUnit(getSrcPriceCts(), getCurrencyCode()), getVatPercentageOrZero()); + return centsToUnit(getSrcPriceCts(), currencyCode).add(rawVat); + } else if(vatStatus == VatStatus.INCLUDED) { + var rawVat = vatStatus.extractRawVAT(centsToUnit(getSrcPriceCts(), getCurrencyCode()), getVatPercentageOrZero()); + return centsToUnit(getSrcPriceCts(), currencyCode).subtract(rawVat); + } else { + return centsToUnit(getSrcPriceCts(), currencyCode); + } + } + + public int getSummarySrcPriceCts() { + if(VatStatus.isVatExempt(getVatStatus())) { + return unitToCents(getFinalPrice(), getCurrencyCode()); + } + return getSrcPriceCts(); + } +} diff --git a/src/main/java/alfio/model/transaction/PaymentContext.java b/src/main/java/alfio/model/transaction/PaymentContext.java index 0e4f6c40f0..f72fe236a6 100644 --- a/src/main/java/alfio/model/transaction/PaymentContext.java +++ b/src/main/java/alfio/model/transaction/PaymentContext.java @@ -17,15 +17,14 @@ package alfio.model.transaction; import alfio.manager.system.ConfigurationLevel; -import alfio.model.BillingDetails; import alfio.model.Event; -import lombok.Data; +import alfio.model.PurchaseContext; import java.util.Optional; public class PaymentContext { - private final Event event; + private final PurchaseContext purchaseContext; private final String reservationId; private final ConfigurationLevel configurationLevel; @@ -33,30 +32,30 @@ public PaymentContext() { this(null, ConfigurationLevel.system()); } - public PaymentContext(Event event) { - this(event, ConfigurationLevel.event(event)); + public PaymentContext(PurchaseContext purchaseContext) { + this(purchaseContext, purchaseContext.getConfigurationLevel()); } - public PaymentContext(Event event, String reservationId) { - this(event, ConfigurationLevel.event(event), reservationId); + public PaymentContext(PurchaseContext purchaseContext, String reservationId) { + this(purchaseContext, purchaseContext.getConfigurationLevel(), reservationId); } - public PaymentContext(Event event, ConfigurationLevel configurationLevel) { - this(event, configurationLevel, null); + public PaymentContext(PurchaseContext purchaseContext, ConfigurationLevel configurationLevel) { + this(purchaseContext, configurationLevel, null); } - public PaymentContext(Event event, ConfigurationLevel configurationLevel, String reservationId) { - this.event = event; + public PaymentContext(PurchaseContext purchaseContext, ConfigurationLevel configurationLevel, String reservationId) { + this.purchaseContext = purchaseContext; this.configurationLevel = configurationLevel; this.reservationId = reservationId; } /** - * The {@link Event} on which this configuration refers to - * @return Event, or null + * The {@link PurchaseContext} on which this configuration refers to + * @return PurchaseContext, or null */ - public Event getEvent() { - return event; + public PurchaseContext getPurchaseContext() { + return purchaseContext; } public Optional getReservationId() { @@ -66,4 +65,8 @@ public Optional getReservationId() { public ConfigurationLevel getConfigurationLevel() { return configurationLevel; } + + public boolean isOnline() { + return purchaseContext.event().map(Event::isOnline).orElse(true); + } } diff --git a/src/main/java/alfio/model/transaction/capabilities/PaymentInfo.java b/src/main/java/alfio/model/transaction/capabilities/PaymentInfo.java index 649056ac30..4b825ce376 100644 --- a/src/main/java/alfio/model/transaction/capabilities/PaymentInfo.java +++ b/src/main/java/alfio/model/transaction/capabilities/PaymentInfo.java @@ -16,8 +16,8 @@ */ package alfio.model.transaction.capabilities; -import alfio.model.Event; import alfio.model.PaymentInformation; +import alfio.model.PurchaseContext; import alfio.model.transaction.Capability; import alfio.model.transaction.Transaction; @@ -25,6 +25,6 @@ public interface PaymentInfo extends Capability { - Optional getInfo(Transaction transaction, Event event); + Optional getInfo(Transaction transaction, PurchaseContext purchaseContext); } diff --git a/src/main/java/alfio/model/transaction/capabilities/RefundRequest.java b/src/main/java/alfio/model/transaction/capabilities/RefundRequest.java index 2c0d30a44c..029a6d08f7 100644 --- a/src/main/java/alfio/model/transaction/capabilities/RefundRequest.java +++ b/src/main/java/alfio/model/transaction/capabilities/RefundRequest.java @@ -16,7 +16,7 @@ */ package alfio.model.transaction.capabilities; -import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.transaction.Capability; /** @@ -24,6 +24,6 @@ */ public interface RefundRequest extends Capability { - boolean refund(alfio.model.transaction.Transaction transaction, Event event, Integer amount); + boolean refund(alfio.model.transaction.Transaction transaction, PurchaseContext purchaseContext, Integer amount); } diff --git a/src/main/java/alfio/model/transaction/capabilities/ServerInitiatedTransaction.java b/src/main/java/alfio/model/transaction/capabilities/ServerInitiatedTransaction.java index 087c207875..6d9862ccd0 100644 --- a/src/main/java/alfio/model/transaction/capabilities/ServerInitiatedTransaction.java +++ b/src/main/java/alfio/model/transaction/capabilities/ServerInitiatedTransaction.java @@ -17,7 +17,7 @@ package alfio.model.transaction.capabilities; import alfio.manager.payment.PaymentSpecification; -import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.transaction.Capability; import alfio.model.transaction.Transaction; import alfio.model.transaction.TransactionInitializationToken; @@ -31,5 +31,5 @@ public interface ServerInitiatedTransaction extends Capability { TransactionInitializationToken errorToken(String errorMessage, boolean reservationStatusChanged); - boolean discardTransaction(Transaction transaction, Event event); + boolean discardTransaction(Transaction transaction, PurchaseContext purchaseContext); } diff --git a/src/main/java/alfio/model/transaction/webhook/MollieWebhookPayload.java b/src/main/java/alfio/model/transaction/webhook/MollieWebhookPayload.java index 26dbfa46ec..f0e11d9f2a 100644 --- a/src/main/java/alfio/model/transaction/webhook/MollieWebhookPayload.java +++ b/src/main/java/alfio/model/transaction/webhook/MollieWebhookPayload.java @@ -16,6 +16,7 @@ */ package alfio.model.transaction.webhook; +import alfio.model.PurchaseContext; import alfio.model.transaction.TransactionWebhookPayload; import lombok.AllArgsConstructor; @@ -23,7 +24,8 @@ public class MollieWebhookPayload implements TransactionWebhookPayload { private final String paymentId; - private final String eventName; + private final PurchaseContext.PurchaseContextType purchaseContextType; + private final String purchaseContextIdentifier; private final String reservationId; @Override @@ -50,7 +52,11 @@ public String getPaymentId() { return paymentId; } - public String getEventName() { - return eventName; + public PurchaseContext.PurchaseContextType getPurchaseContextType() { + return purchaseContextType; + } + + public String getPurchaseContextIdentifier() { + return purchaseContextIdentifier; } } diff --git a/src/main/java/alfio/repository/AuditingRepository.java b/src/main/java/alfio/repository/AuditingRepository.java index 1f09601ed1..9357a78ab2 100644 --- a/src/main/java/alfio/repository/AuditingRepository.java +++ b/src/main/java/alfio/repository/AuditingRepository.java @@ -18,6 +18,8 @@ import alfio.model.Audit; +import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.support.JSONData; import alfio.util.Json; import ch.digitalfondue.npjt.Bind; @@ -36,23 +38,35 @@ public interface AuditingRepository { @Query("insert into auditing(reservation_id, user_id, event_id, event_type, event_time, entity_type, entity_id, modifications) " + " values (:reservationId, :userId, :eventId, :eventType, :eventTime, :entityType, :entityId, :modifications)") int insert(@Bind("reservationId") String reservationId, @Bind("userId") Integer userId, - @Bind("eventId") int eventId, + @Bind("eventId") Integer eventId, @Bind("eventType") Audit.EventType eventType, @Bind("eventTime") Date eventTime, @Bind("entityType") Audit.EntityType entityType, @Bind("entityId") String entityId, @Bind("modifications") String modifications); - default int insert(String reservationId, Integer userId, int eventId, Audit.EventType eventType, Date eventTime, Audit.EntityType entityType, + default int insert(String reservationId, Integer userId, Integer eventId, Audit.EventType eventType, Date eventTime, Audit.EntityType entityType, String entityId) { return this.insert(reservationId, userId, eventId, eventType, eventTime, entityType, entityId, (String) null); } - default int insert(String reservationId, Integer userId, int eventId, Audit.EventType eventType, Date eventTime, Audit.EntityType entityType, + default int insert(String reservationId, Integer userId, Integer eventId, Audit.EventType eventType, Date eventTime, Audit.EntityType entityType, String entityId, List> modifications) { String modificationJson = modifications == null ? null : Json.toJson(modifications); return this.insert(reservationId, userId, eventId, eventType, eventTime, entityType, entityId, modificationJson); } + default int insert(String reservationId, Integer userId, PurchaseContext p, Audit.EventType eventType, Date eventTime, Audit.EntityType entityType, + String entityId) { + var eventId = p.event().map(Event::getId).orElse(null); + return this.insert(reservationId, userId, eventId, eventType, eventTime, entityType, entityId, (String) null); + } + + default int insert(String reservationId, Integer userId, PurchaseContext p, Audit.EventType eventType, Date eventTime, Audit.EntityType entityType, + String entityId, List> modifications) { + var eventId = p.event().map(Event::getId).orElse(null); + return insert(reservationId, userId, eventId, eventType, eventTime, entityType, entityId, modifications); + } + @Query("select * from auditing_user where reservation_id = :reservationId order by event_time asc") List findAllForReservation(@Bind("reservationId") String reservationId); diff --git a/src/main/java/alfio/repository/BillingDocumentRepository.java b/src/main/java/alfio/repository/BillingDocumentRepository.java index 478b2e59da..047607f11a 100644 --- a/src/main/java/alfio/repository/BillingDocumentRepository.java +++ b/src/main/java/alfio/repository/BillingDocumentRepository.java @@ -44,7 +44,7 @@ public interface BillingDocumentRepository { @Query("insert into billing_document(event_id_fk, number, reservation_id_fk, type, model, generation_ts, status, organization_id_fk)" + " values(:eventId, :number, :reservationId, :type, :model, :generationTimestamp, 'VALID', :organizationId)") @AutoGeneratedKey("id") - AffectedRowCountAndKey insert(@Bind("eventId") int eventId, + AffectedRowCountAndKey insert(@Bind("eventId") Integer eventId, @Bind("reservationId") String reservationId, @Bind("number") String number, @Bind("type") BillingDocument.Type type, @@ -57,8 +57,8 @@ AffectedRowCountAndKey insert(@Bind("eventId") int eventId, " on a.generation_ts = b.time and a.reservation_id_fk = b.reservation_id_fk") List findAllOfTypeForEvent(@Bind("type") BillingDocument.Type type, @Bind("eventId") int eventId); - @Query("delete from billing_document where reservation_id_fk = :reservationId and event_id_fk = :eventId") - int deleteForReservation(@Bind("reservationId") String reservationId, @Bind("eventId") int eventId); + @Query("delete from billing_document where reservation_id_fk = :reservationId") + int deleteForReservation(@Bind("reservationId") String reservationId); @Query("delete from billing_document where reservation_id_fk in (:reservationIds) and event_id_fk = :eventId") int deleteForReservations(@Bind("reservationIds") List reservationIds, @Bind("eventId") int eventId); diff --git a/src/main/java/alfio/repository/EmailMessageRepository.java b/src/main/java/alfio/repository/EmailMessageRepository.java index 235f6bc9b8..880453e8a9 100644 --- a/src/main/java/alfio/repository/EmailMessageRepository.java +++ b/src/main/java/alfio/repository/EmailMessageRepository.java @@ -17,30 +17,41 @@ package alfio.repository; import alfio.model.EmailMessage; +import alfio.model.Event; import alfio.model.LightweightMailMessage; +import alfio.model.PurchaseContext; +import alfio.model.subscription.SubscriptionDescriptor; import ch.digitalfondue.npjt.Bind; import ch.digitalfondue.npjt.Query; import ch.digitalfondue.npjt.QueryRepository; +import ch.digitalfondue.npjt.QueryType; import java.time.ZonedDateTime; -import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.UUID; @QueryRepository public interface EmailMessageRepository { - /** - * This method returns a lightweight instance of EmailMessage. The property "Attachments" is always null. - * @param eventId - * @param checksum - * @return - */ @Query("select id from email_message where event_id = :eventId and checksum = :checksum limit 1") Optional findIdByEventIdAndChecksum(@Bind("eventId") int eventId, @Bind("checksum") String checksum); - @Query("insert into email_message (event_id, reservation_id, status, recipient, subject, message, html_message, attachments, checksum, request_ts, email_cc) values(:eventId, :reservationId, 'WAITING', :recipient, :subject, :message, :htmlMessage, :attachments, :checksum, :timestamp, :emailCC)") - int insert(@Bind("eventId") int eventId, + @Query("select id from email_message where subscription_descriptor_id_fk = :subscriptionDescriptorId and checksum = :checksum limit 1") + Optional findIdBySubscriptionDescriptorAndChecksum(@Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, @Bind("checksum") String checksum); + + default Optional findIdByPurchaseContextAndChecksum(PurchaseContext purchaseContext, String checksum) { + if(purchaseContext.getType() == PurchaseContext.PurchaseContextType.event) { + return findIdByEventIdAndChecksum(((Event)purchaseContext).getId(), checksum); + } else { + return findIdBySubscriptionDescriptorAndChecksum(((SubscriptionDescriptor) purchaseContext).getId(), checksum); + } + } + + @Query("insert into email_message (event_id, organization_id_fk, subscription_descriptor_id_fk, reservation_id, status, recipient, subject, message, html_message, attachments, checksum, request_ts, email_cc)" + + " values(:eventId, :organizationId, :subscriptionDescriptorId, :reservationId, 'WAITING', :recipient, :subject, :message, :htmlMessage, :attachments, :checksum, :timestamp, :emailCC)") + int insert(@Bind("eventId") Integer eventId, + @Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, @Bind("reservationId") String reservationId, @Bind("recipient") String recipient, @Bind("emailCC") String cc, @@ -49,47 +60,76 @@ int insert(@Bind("eventId") int eventId, @Bind("htmlMessage") String htmlMessage, @Bind("attachments") String attachments, @Bind("checksum") String checksum, - @Bind("timestamp") ZonedDateTime requestTimestamp); + @Bind("timestamp") ZonedDateTime requestTimestamp, + @Bind("organizationId") int organizationId); - @Query("update email_message set status = :status where event_id = :eventId and checksum = :checksum and status in (:expectedStatuses)") - int updateStatus(@Bind("eventId") int eventId, @Bind("checksum") String checksum, @Bind("status") String status, @Bind("expectedStatuses") List expectedStatuses); + @Query("update email_message set status = :status where id = :id and checksum = :checksum and status in (:expectedStatuses)") + int updateStatus(@Bind("id") int messageId, @Bind("checksum") String checksum, @Bind("status") String status, @Bind("expectedStatuses") List expectedStatuses); - @Query("update email_message set status = 'WAITING', html_message = :htmlMessage where id = :messageId and event_id = :eventId") - int updateStatusToWaitingWithHtml(@Bind("eventId") int eventId, @Bind("messageId") int messageId, @Bind("htmlMessage") String htmlMessage); + @Query("update email_message set status = 'WAITING', html_message = :htmlMessage where id = :messageId") + int updateStatusToWaitingWithHtml(@Bind("messageId") int messageId, @Bind("htmlMessage") String htmlMessage); @Query("update email_message set status = :status, attempts = :attempts where id = :messageId and status in (:expectedStatuses) ") int updateStatusAndAttempts(@Bind("messageId") int messageId, @Bind("status") String status, @Bind("attempts") int attempts, @Bind("expectedStatuses") List expectedStatuses); @Query("update email_message set status = :status, attempts = :attempts, request_ts = :nextDate where id = :messageId and status in (:expectedStatuses) ") - int updateStatusAndAttempts(@Bind("messageId") int messageId, @Bind("status") String status, @Bind("nextDate") Date date, @Bind("attempts") int attempts, @Bind("expectedStatuses") List expectedStatuses); + int updateStatusAndAttempts(@Bind("messageId") int messageId, @Bind("status") String status, @Bind("nextDate") ZonedDateTime date, @Bind("attempts") int attempts, @Bind("expectedStatuses") List expectedStatuses); - @Query("select id from email_message where event_id = :eventId and (status = 'WAITING' or status = 'RETRY') and request_ts <= :date limit 100 for update skip locked") - List loadIdsWaitingForProcessing(@Bind("eventId") int eventId, @Bind("date") Date date); + @Query(type = QueryType.SELECT, + value = "select * from email_message" + + " where (" + + " (event_id is not null and event_id in (select id from event where end_ts > now())) or " + + " (subscription_descriptor_id_fk is not null and subscription_descriptor_id_fk in (select id from subscription_descriptor where validity_to is null or validity_to > now())) " + + ") and (status = 'WAITING' or status = 'RETRY') limit 100 for update skip locked") + List loadAllWaitingForProcessing(); - @Query("update email_message set status = 'SENT', sent_ts = :sentTimestamp, html_message = null where event_id = :eventId and checksum = :checksum and status in (:expectedStatuses)") - int updateStatusToSent(@Bind("eventId") int eventId, @Bind("checksum") String checksum, @Bind("sentTimestamp") ZonedDateTime sentTimestamp, @Bind("expectedStatuses") List expectedStatuses); + @Query("update email_message set status = 'SENT', sent_ts = :sentTimestamp, html_message = null where id = :id and checksum = :checksum and status in (:expectedStatuses)") + int updateStatusToSent(@Bind("id") int id, @Bind("checksum") String checksum, @Bind("sentTimestamp") ZonedDateTime sentTimestamp, @Bind("expectedStatuses") List expectedStatuses); - String FIND_MAILS = "select id, event_id, status, recipient, subject, message, checksum, request_ts, sent_ts, attempts, email_cc from email_message where event_id = :eventId and " + + String LIGHTWEIGHT_FIELDS = "id, event_id, subscription_descriptor_id_fk, status, recipient, subject, message, checksum, request_ts, sent_ts, attempts, email_cc, organization_id_fk "; + String FIND_MAILS_BY_EVENT = "select " + LIGHTWEIGHT_FIELDS + " from email_message where event_id = :eventId and " + " (:search is null or (recipient like :search or subject like :search or message like :search)) order by sent_ts desc, id "; - @Query("select * from (" + FIND_MAILS +"limit :pageSize offset :page) as d_tbl") + String FIND_MAILS_BY_SUBSCRIPTION = "select " + LIGHTWEIGHT_FIELDS + " from email_message where subscription_descriptor_id_fk = :subscriptionId and " + + " (:search is null or (recipient like :search or subject like :search or message like :search)) order by sent_ts desc, id "; + + @Query("select * from (" + FIND_MAILS_BY_EVENT +" limit :pageSize offset :page) as d_tbl") List findByEventId(@Bind("eventId") int eventId, @Bind("page") int page, @Bind("pageSize") int pageSize, @Bind("search") String search); - @Query("select id, event_id, status, recipient, subject, message, checksum, request_ts, sent_ts, attempts, email_cc from email_message where event_id = :eventId and reservation_id = :reservationId order by sent_ts desc, id") + @Query("select id, event_id, status, recipient, subject, message, checksum, request_ts, sent_ts, attempts, email_cc, subscription_descriptor_id_fk, organization_id_fk from email_message where event_id = :eventId and reservation_id = :reservationId order by sent_ts desc, id") List findByEventIdAndReservationId(@Bind("eventId") int eventId, @Bind("reservationId") String reservationId); - @Query("select count(*) from (" + FIND_MAILS + ") as d_tbl") + @Query("select * from (" + FIND_MAILS_BY_SUBSCRIPTION + " limit :pageSize offset :page) as d_tbl") + List findBySubscriptionDescriptorId(@Bind("subscriptionId") UUID subscriptionDescriptorId, @Bind("page") int page, @Bind("pageSize") int pageSize, @Bind("search") String search); + + @Query("select id, event_id, status, recipient, subject, message, checksum, request_ts, sent_ts, attempts, email_cc, subscription_descriptor_id_fk, organization_id_fk from email_message where subscription_descriptor_id_fk = :subscriptionDescriptorId and reservation_id = :reservationId order by sent_ts desc, id") + List findBySubscriptionDescriptorAndReservationId(@Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, @Bind("reservationId") String reservationId); + + default List findByPurchaseContextAndReservationId(PurchaseContext purchaseContext, String reservationId) { + if(purchaseContext.getType() == PurchaseContext.PurchaseContextType.event) { + return findByEventIdAndReservationId(((Event)purchaseContext).getId(), reservationId); + } else { + return findBySubscriptionDescriptorAndReservationId(((SubscriptionDescriptor)purchaseContext).getId(), reservationId); + } + } + + @Query("select count(*) from (" + FIND_MAILS_BY_EVENT + ") as d_tbl") Integer countFindByEventId(@Bind("eventId") int eventId, @Bind("search") String search); + @Query("select count(*) from ("+FIND_MAILS_BY_SUBSCRIPTION+") as d_tbl") + Integer countFindBySubscriptionDescriptorId(@Bind("subscriptionId") UUID subscriptionId, @Bind("search") String search); @Query("select * from email_message where id = :id") EmailMessage findById(@Bind("id") int id); - @Query("select * from email_message where id = :messageId and event_id = :eventId") - Optional findByEventIdAndMessageId(@Bind("eventId") int eventId, @Bind("messageId") int messageId); + @Query("select "+LIGHTWEIGHT_FIELDS+" from email_message where id = :messageId and event_id = :eventId") + Optional findByEventIdAndMessageId(@Bind("eventId") int eventId, @Bind("messageId") int messageId); + + @Query("select "+LIGHTWEIGHT_FIELDS+" from email_message where id = :messageId and subscription_descriptor_id_fk = :subscriptionId") + Optional findBySubscriptionDescriptorIdAndMessageId(@Bind("subscriptionId") UUID subscriptionId, @Bind("messageId") int messageId); @Query("update email_message set status = 'RETRY', attempts = coalesce(attempts, 0) +1 where status = 'IN_PROCESS' and request_ts < :date") - int setToRetryOldInProcess(@Bind("date") Date date); + int setToRetryOldInProcess(@Bind("date") ZonedDateTime date); } diff --git a/src/main/java/alfio/repository/EventDeleterRepository.java b/src/main/java/alfio/repository/EventDeleterRepository.java index b6c25dba71..6fde06e338 100644 --- a/src/main/java/alfio/repository/EventDeleterRepository.java +++ b/src/main/java/alfio/repository/EventDeleterRepository.java @@ -107,6 +107,9 @@ public interface EventDeleterRepository { @Query("delete from poll where event_id_fk = :eventId") int deletePolls(@Bind("eventId") int eventId); + @Query("delete from subscription_event where event_id_fk = :eventId") + int deleteSubscriptionLinks(@Bind("eventId") int eventId); + default void deleteAllForEvent(int eventId) { deletePolls(eventId); deleteWaitingQueue(eventId); @@ -135,6 +138,7 @@ default void deleteAllForEvent(int eventId) { deleteEventDescription(eventId); deleteResources(eventId); deleteScanAudit(eventId); + deleteSubscriptionLinks(eventId); deleteEvent(eventId); } diff --git a/src/main/java/alfio/repository/EventRepository.java b/src/main/java/alfio/repository/EventRepository.java index 1c1b6b299c..eee72c540d 100644 --- a/src/main/java/alfio/repository/EventRepository.java +++ b/src/main/java/alfio/repository/EventRepository.java @@ -202,4 +202,12 @@ int updatePrices(@Bind("currency") String currency, @Query("update event set metadata = :metadata::jsonb where id = :eventId") int updateMetadata(@Bind("metadata") @JSONData AlfioMetadata metadata, @Bind("eventId") int eventId); + + @Query("select * from event where id in (select distinct id from basic_event_with_optional_subscription where end_ts > now() and status = 'PUBLIC'" + + " and (:subscriptionId::uuid is null or subscription_id = :subscriptionId::uuid)" + + " and (:organizer::integer is null or org_id = :organizer)" + + " and (:tags::text[] is null or tags @> ARRAY[ :tags ]::text[]))") + List findVisibleBySearchOptions(@Bind("subscriptionId") UUID subscriptionId, + @Bind("organizer") Integer organizer, + @Bind("tags") List tags); } diff --git a/src/main/java/alfio/repository/FileUploadRepository.java b/src/main/java/alfio/repository/FileUploadRepository.java index f79a8efe77..7040edbddb 100644 --- a/src/main/java/alfio/repository/FileUploadRepository.java +++ b/src/main/java/alfio/repository/FileUploadRepository.java @@ -46,7 +46,11 @@ public interface FileUploadRepository { @Query("select id, name, content_size, content_type, attributes from file_blob where id = :id") Optional findById(@Bind("id") String id); - @Query("delete from file_blob where creation_time <= :date and id not in (select file_blob_id from event where file_blob_id is not null)") + @Query("delete from file_blob where creation_time <= :date and id not in (" + + "select file_blob_id from event where file_blob_id is not null" + + " union " + + "select file_blob_id_fk as file_blob_id from subscription_descriptor where file_blob_id_fk is not null" + + ")") int cleanupUnreferencedBlobFiles(@Bind("date") Date date); default void upload(UploadBase64FileModification file, String digest, Map attributes) { diff --git a/src/main/java/alfio/repository/SubscriptionRepository.java b/src/main/java/alfio/repository/SubscriptionRepository.java new file mode 100644 index 0000000000..36f20df89c --- /dev/null +++ b/src/main/java/alfio/repository/SubscriptionRepository.java @@ -0,0 +1,259 @@ +/** + * 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.repository; + +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.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; +import ch.digitalfondue.npjt.Bind; +import ch.digitalfondue.npjt.Query; +import ch.digitalfondue.npjt.QueryRepository; +import ch.digitalfondue.npjt.QueryType; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@QueryRepository +public interface SubscriptionRepository { + + String FETCH_SUBSCRIPTION_LINK = "select sd.id subscription_descriptor_id, sd.title subscription_descriptor_title, e.id event_id," + + " e.short_name event_short_name, e.display_name event_display_name, e.currency event_currency, se.price_per_ticket price_per_ticket " + + " from subscription_event se" + + " join event e on e.id = se.event_id_fk and e.org_id = :organizationId" + + " join subscription_descriptor sd on sd.id = se.subscription_descriptor_id_fk and sd.organization_id_fk = :organizationId"; + + String INSERT_SUBSCRIPTION_LINK = "insert into subscription_event(event_id_fk, subscription_descriptor_id_fk, price_per_ticket, organization_id_fk)" + + " values(:eventId, :subscriptionId, :pricePerTicket, :organizationId) on conflict(subscription_descriptor_id_fk, event_id_fk) do update set price_per_ticket = excluded.price_per_ticket"; + + String INSERT_SUBSCRIPTION = "insert into subscription(id, subscription_descriptor_fk, reservation_id_fk, max_usage, usage_count, " + + " valid_from, valid_to, organization_id_fk, status, src_price_cts, currency) values (:id, :subscriptionDescriptorId, :reservationId, :maxUsage, 0, :validFrom, :validTo, :organizationId, :status::ALLOCATION_STATUS, :srcPriceCts, :currency)"; + + @Query("insert into subscription_descriptor (" + + "id, title, description, max_available, on_sale_from, on_sale_to, price_cts, vat, vat_status, currency, is_public, organization_id_fk, " + + " max_entries, validity_type, validity_time_unit, validity_units, validity_from, validity_to, usage_type, terms_conditions_url, privacy_policy_url," + + " file_blob_id_fk, allowed_payment_proxies, private_key, time_zone) " + + " values(:id, :title::jsonb, :description::jsonb, :maxAvailable, :onSaleFrom, :onSaleTo, :priceCts, :vat, :vatStatus::VAT_STATUS, :currency, " + + " :isPublic, :organizationId, :maxEntries, :validityType::SUBSCRIPTION_VALIDITY_TYPE, :validityTimeUnit::SUBSCRIPTION_TIME_UNIT, " + + " :validityUnits, :validityFrom, :validityTo, :usageType::SUBSCRIPTION_USAGE_TYPE, :tcUrl, :privacyPolicyUrl," + + " :fileBlobId, :allowedPaymentProxies::text[], :privateKey, :timeZone)") + int createSubscriptionDescriptor(@Bind("id") UUID id, + @Bind("title") @JSONData Map title, + @Bind("description") @JSONData Map description, + @Bind("maxAvailable") int maxAvailable, + @Bind("onSaleFrom") ZonedDateTime onSaleFrom, + @Bind("onSaleTo") ZonedDateTime onSaleTo, + @Bind("priceCts") int priceCts, + @Bind("vat") BigDecimal vat, + @Bind("vatStatus") VatStatus vatStatus, + @Bind("currency") String currency, + @Bind("isPublic") boolean isPublic, + @Bind("organizationId") int organizationId, + + @Bind("maxEntries") int maxEntries, + @Bind("validityType") SubscriptionValidityType validityType, + @Bind("validityTimeUnit") SubscriptionTimeUnit validityTimeUnit, + @Bind("validityUnits") Integer validityUnits, + @Bind("validityFrom") ZonedDateTime validityFrom, + @Bind("validityTo") ZonedDateTime validityTo, + @Bind("usageType") SubscriptionUsageType usageType, + + @Bind("tcUrl") String termsConditionsUrl, + @Bind("privacyPolicyUrl") String privacyPolicyUrl, + @Bind("fileBlobId") String fileBlobId, + @Bind("allowedPaymentProxies") @Array List allowedPaymentProxies, + @Bind("privateKey") String privateKey, + @Bind("timeZone") String timeZone); + + @Query("update subscription_descriptor set title = :title::jsonb, description = :description::jsonb, max_available = :maxAvailable," + + " on_sale_from = :onSaleFrom, on_sale_to = :onSaleTo, price_cts = :priceCts, vat = :vat, vat_status = :vatStatus::VAT_STATUS, " + + " currency = :currency, is_public = :isPublic, max_entries = :maxEntries, validity_type = :validityType::SUBSCRIPTION_VALIDITY_TYPE," + + " validity_time_unit = :validityTimeUnit::SUBSCRIPTION_TIME_UNIT, validity_units = :validityUnits, validity_from = :validityFrom," + + " validity_to = :validityTo, usage_type = :usageType::SUBSCRIPTION_USAGE_TYPE," + + " terms_conditions_url = :tcUrl, privacy_policy_url = :privacyPolicyUrl, file_blob_id_fk = :fileBlobId," + + " allowed_payment_proxies = :allowedPaymentProxies::text[], time_zone = :timeZone " + + " where id = :id and organization_id_fk = :organizationId") + int updateSubscriptionDescriptor(@Bind("title") @JSONData Map title, + @Bind("description") @JSONData Map description, + @Bind("maxAvailable") int maxAvailable, + @Bind("onSaleFrom") ZonedDateTime onSaleFrom, + @Bind("onSaleTo") ZonedDateTime onSaleTo, + @Bind("priceCts") int priceCts, + @Bind("vat") BigDecimal vat, + @Bind("vatStatus") VatStatus vatStatus, + @Bind("currency") String currency, + @Bind("isPublic") boolean isPublic, + + @Bind("maxEntries") int maxEntries, + @Bind("validityType") SubscriptionValidityType validityType, + @Bind("validityTimeUnit") SubscriptionTimeUnit validityTimeUnit, + @Bind("validityUnits") Integer validityUnits, + @Bind("validityFrom") ZonedDateTime validityFrom, + @Bind("validityTo") ZonedDateTime validityTo, + @Bind("usageType") SubscriptionUsageType usageType, + + @Bind("tcUrl") String termsConditionsUrl, + @Bind("privacyPolicyUrl") String privacyPolicyUrl, + @Bind("fileBlobId") String fileBlobId, + @Bind("allowedPaymentProxies") @Array List allowedPaymentProxies, + + @Bind("id") UUID id, + @Bind("organizationId") int organizationId, + @Bind("timeZone") String timeZone); + + @Query("update subscription_descriptor set is_public = :isPublic where id = :id and organization_id_fk = :organizationId") + int setPublicStatus(@Bind("id") UUID id, @Bind("organizationId") int organizationId, @Bind("isPublic") boolean isPublic); + + @Query("select * from subscription_descriptor where organization_id_fk = :organizationId order by creation_ts asc") + List findAllByOrganizationIds(@Bind("organizationId") int organizationId); + + @Query("select * from subscription_descriptor where is_public = true and (max_entries > 0 or max_entries = -1) and (on_sale_from is null or :from >= on_sale_from) and (on_sale_to is null or :from <= on_sale_to) order by on_sale_from asc") + List findAllActiveAndPublic(@Bind("from") ZonedDateTime from); + + @Query("select * from subscription_descriptor where id = :id and organization_id_fk = :organizationId") + Optional findOne(@Bind("id") UUID id, @Bind("organizationId") int organizationId); + + @Query("select * from subscription_descriptor where id = :id") + Optional findOne(@Bind("id") UUID id); + + @Query("select * from subscription_descriptor where id = (select subscription_descriptor_fk from subscription where reservation_id_fk = :reservationId)") + Optional findDescriptorByReservationId(@Bind("reservationId") String reservationId); + + @Query("select * from subscription_descriptor where id = (select subscription_descriptor_fk from subscription where id = :id)") + SubscriptionDescriptor findDescriptorBySubscriptionId(@Bind("id") UUID subscriptionId); + + @Query("select * from subscription_descriptor_statistics where sd_organization_id_fk = :organizationId") + List findAllWithStatistics(@Bind("organizationId") int organizationId); + + @Query(INSERT_SUBSCRIPTION_LINK) + int linkSubscriptionAndEvent(@Bind("subscriptionId") UUID subscriptionId, + @Bind("eventId") int eventId, + @Bind("pricePerTicket") int pricePerTicket, + @Bind("organizationId") int organizationId); + + @Query(type = QueryType.TEMPLATE, value = INSERT_SUBSCRIPTION_LINK) + String insertSubscriptionEventLink(); + + @Query(FETCH_SUBSCRIPTION_LINK + " where se.subscription_descriptor_id_fk = :subscriptionId") + List findLinkedEvents(@Bind("organizationId") int organizationId, + @Bind("subscriptionId") UUID id); + + @Query(FETCH_SUBSCRIPTION_LINK + " where se.event_id_fk = :eventId") + List findLinkedSubscriptions(@Bind("organizationId") int organizationId, + @Bind("eventId") int eventId); + + @Query("select subscription_descriptor_id_fk from subscription_event where event_id_fk = :eventId and organization_id_fk = :organizationId") + List findLinkedSubscriptionIds(@Bind("eventId") int eventId, @Bind("organizationId") int organizationId); + + @Query("delete from subscription_event where event_id_fk = :eventId and organization_id_fk = :organizationId" + + " and subscription_descriptor_id_fk not in (:descriptorIds)") + int removeStaleSubscriptions(@Bind("eventId") int eventId, + @Bind("organizationId") int organizationId, + @Bind("descriptorIds") List currentDescriptors); + + @Query("delete from subscription_event where event_id_fk = :eventId and organization_id_fk = :organizationId") + int removeAllSubscriptionsForEvent(@Bind("eventId") int eventId, + @Bind("organizationId") int organizationId); + + @Query(type = QueryType.TEMPLATE, value = INSERT_SUBSCRIPTION) + String batchCreateSubscription(); + + @Query(INSERT_SUBSCRIPTION) + int createSubscription(@Bind("id") UUID id, + @Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, + @Bind("reservationId") String reservationId, + @Bind("maxUsage") int maxUsage, + @Bind("validFrom") ZonedDateTime validFrom, + @Bind("validTo") ZonedDateTime validTo, + @Bind("srcPriceCts") int srcPriceCts, + @Bind("currency") String currency, + @Bind("organizationId") int organizationId, + @Bind("status") AllocationStatus status); + + @Query("select id from subscription where subscription_descriptor_fk = :descriptorId and status = 'FREE' limit 1 for update skip locked") + Optional selectFreeSubscription(@Bind("descriptorId") UUID subscriptionDescriptorId); + + @Query("update subscription set reservation_id_fk = :reservationId, status = :status::allocation_status where id = :subscriptionId") + int bindSubscriptionToReservation(@Bind("reservationId") String reservationId, @Bind("status") AllocationStatus allocationStatus, @Bind("subscriptionId") UUID subscriptionId); + + @Query("delete from subscription where reservation_id_fk in (:expiredReservationIds)") + int deleteSubscriptionWithReservationId(@Bind("expiredReservationIds") List expiredReservationIds); + + + @Query("select * from subscription where reservation_id_fk = :reservationId") + List findSubscriptionsByReservationId(@Bind("reservationId") String reservationId); + + @Query("select exists(select 1 from subscription_descriptor where id = :id)") + boolean existsById(@Bind("id") UUID subscriptionId); + + @Query("select exists (select id from subscription_event where event_id_fk = :eventId)") + boolean hasLinkedSubscription(@Bind("eventId") int eventId); + + @Query("select * from subscription where id = :id for update") + Subscription findSubscriptionByIdForUpdate(@Bind("id") UUID id); + + @Query("select * from subscription where id = :id") + Subscription findSubscriptionById(@Bind("id") UUID id); + + @Query("select count(*) from subscription where substring(replace(id::text,'-',''), 0, 11) like concat(:partialUuid, '%') and email_address = :email") + int countSubscriptionByPartialUuidAndEmail(@Bind("partialUuid") String partialUuid, @Bind("email") String email); + + @Query("select count(*) from subscription where substring(replace(id::text,'-',''), 0, 11) like concat(:partialUuid, '%')") + int countSubscriptionByPartialUuid(@Bind("partialUuid") String partialUuid); + + @Query("select id from subscription where substring(replace(id::text,'-',''), 0, 11) like concat(:partialUuid, '%') and email_address = :email") + UUID getSubscriptionIdByPartialUuidAndEmail(@Bind("partialUuid") String partialUuid, @Bind("email") String email); + + @Query("select id from subscription where substring(replace(id::text,'-',''), 0, 11) like concat(:partialUuid, '%')") + UUID getSubscriptionIdByPartialUuid(@Bind("partialUuid") String partialUuid); + + @Query("update subscription set usage_count = usage_count + 1 where id = :id") + int increaseUse(@Bind("id") UUID id); + + @Query("select * from subscription where id = (select subscription_id_fk from tickets_reservation where id = :reservationId)") + Optional findAppliedSubscriptionByReservationId(@Bind("reservationId") String id); + + @Query("select sd.* from subscription_descriptor sd" + + " join subscription s on s.subscription_descriptor_fk = sd.id" + + " join tickets_reservation tr on tr.subscription_id_fk = s.id" + + " where tr.id = :reservationId") + Optional findDescriptorForAppliedSubscription(@Bind("reservationId") String reservationId); + + @Query("update subscription set usage_count = (case when usage_count > 0 then usage_count - 1 else usage_count end) where id in (select subscription_id_fk from tickets_reservation where id in (:reservationIds) and subscription_id_fk is not null)") + int decrementUse(@Bind("reservationIds") List reservationIds); + + @Query("select count(*) from subscription where id = :id") + int countSubscriptionById(@Bind("id") UUID fromString); + + @Query("update subscription set status = :status::allocation_status, first_name = :firstName, last_name = :lastName, email_address = :email where reservation_id_fk = :reservationId") + int updateSubscriptionStatus(@Bind("reservationId") String reservationId, @Bind("status") AllocationStatus status, @Bind("firstName") String firstName, @Bind("lastName") String lastName, @Bind("email") String email); + + @Query("update subscription set status = 'INVALIDATED' where subscription_descriptor_fk = :descriptorId and status = 'FREE' limit :amount") + int invalidateSubscriptions(@Bind("descriptorId") UUID subscriptionDescriptorId, @Bind("amount") int amount); +} diff --git a/src/main/java/alfio/repository/TicketRepository.java b/src/main/java/alfio/repository/TicketRepository.java index f74891b521..840e60d568 100644 --- a/src/main/java/alfio/repository/TicketRepository.java +++ b/src/main/java/alfio/repository/TicketRepository.java @@ -167,9 +167,6 @@ default void bulkTicketUpdate(List ids, TicketCategory ticketCategory) @Query("select * from ticket where uuid = :uuid for update") Optional findByUUIDForUpdate(@Bind("uuid") String uuid); - @Query("select count(id) from ticket where event_id = :eventId and status = :status and uuid like ':uuid%'") - Integer countByEventIdPartialUUIDAndStatus(@Bind("eventId") int eventId, @Bind("uuid") String partialUUID, @Bind("status") Ticket.TicketStatus status); - @Query("select * from ticket where event_id = :eventId and status = :status and uuid like :uuid for update") List findByEventIdAndPartialUUIDForUpdate(@Bind("eventId") int eventId, @Bind("uuid") String partialUUID, @Bind("status") Ticket.TicketStatus status); diff --git a/src/main/java/alfio/repository/TicketReservationRepository.java b/src/main/java/alfio/repository/TicketReservationRepository.java index afd01d6891..43f1db99a8 100644 --- a/src/main/java/alfio/repository/TicketReservationRepository.java +++ b/src/main/java/alfio/repository/TicketReservationRepository.java @@ -23,24 +23,22 @@ import java.math.BigDecimal; import java.time.ZonedDateTime; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Optional; +import java.util.*; @QueryRepository public interface TicketReservationRepository { - @Query("insert into tickets_reservation(id, creation_ts, validity, promo_code_id_fk, status, user_language, event_id_fk, used_vat_percent, vat_included, currency_code)" + - " values (:id, :creationTimestamp, :validity, :promotionCodeDiscountId, 'PENDING', :userLanguage, :eventId, :eventVat, :vatIncluded, :currencyCode)") + @Query("insert into tickets_reservation(id, creation_ts, validity, promo_code_id_fk, status, user_language, event_id_fk, used_vat_percent, vat_included, currency_code, organization_id_fk)" + + " values (:id, :creationTimestamp, :validity, :promotionCodeDiscountId, 'PENDING', :userLanguage, :eventId, :vatPercentage, :vatIncluded, :currencyCode, :organizationId)") int createNewReservation(@Bind("id") String id, @Bind("creationTimestamp") ZonedDateTime creationTimestamp, @Bind("validity") Date validity, @Bind("promotionCodeDiscountId") Integer promotionCodeDiscountId, @Bind("userLanguage") String userLanguage, - @Bind("eventId") int eventId, - @Bind("eventVat") BigDecimal eventVat, + @Bind("eventId") Integer eventId, // <- optional + @Bind("vatPercentage") BigDecimal vatPercentage, @Bind("vatIncluded") Boolean vatIncluded, - @Bind("currencyCode") String currencyCode); + @Bind("currencyCode") String currencyCode, + @Bind("organizationId") int organizationId); @Query("update tickets_reservation set status = :status, full_name = :fullName, first_name = :firstName, last_name = :lastName, email_address = :email," + " user_language = :userLanguage, billing_address = :billingAddress, confirmation_ts = :timestamp, payment_method = :paymentMethod, customer_reference = :customerReference where id = :reservationId") @@ -176,7 +174,7 @@ int updateBillingData(@Bind("vatStatus") PriceContainer.VatStatus vatStatus, - @Query("select id, event_id_fk from tickets_reservation where id in (:ids)") + @Query("select id, event_id_fk from tickets_reservation where id in (:ids) and event_id_fk is not null") List getReservationIdAndEventId(@Bind("ids") Collection ids); @Query("select * from tickets_reservation where id in (:ids)") @@ -257,4 +255,13 @@ default Integer countTicketsInReservationForCategories(String reservationId, Col @Query("update tickets_reservation set invoicing_additional_information = :info::json where id = :id") int updateInvoicingAdditionalInformation(@Bind("id") String reservationId, @Bind("info") String info); + + @Query("select event_id_fk from tickets_reservation where id = :id") + Optional findEventIdFor(@Bind("id") String reservationId); + + @Query("select exists (select * from tickets_reservation where id = :id and subscription_id_fk is not null)") + boolean hasSubscriptionApplied(@Bind("id") String id); + + @Query("update tickets_reservation set subscription_id_fk = :subscriptionId where id = :reservationId") + int applySubscription(@Bind("reservationId") String reservationId, @Bind("subscriptionId") UUID subscriptionId); } diff --git a/src/main/java/alfio/repository/TicketSearchRepository.java b/src/main/java/alfio/repository/TicketSearchRepository.java index e56ba28105..c6d9c98a5d 100644 --- a/src/main/java/alfio/repository/TicketSearchRepository.java +++ b/src/main/java/alfio/repository/TicketSearchRepository.java @@ -27,18 +27,24 @@ import java.util.Collection; import java.util.List; +import java.util.UUID; @QueryRepository public interface TicketSearchRepository { String APPLY_FILTER = " (:search is null or (lower(tr_id) like lower(:search) or lower(t_uuid) like lower(:search) or lower(t_full_name) like lower(:search) or lower(t_first_name) like lower(:search) or lower(t_last_name) like lower(:search) or lower(t_email_address) like lower(:search) or " + " lower(tr_full_name) like lower(:search) or lower(tr_first_name) like lower(:search) or lower(tr_last_name) like lower(:search) or lower(tr_email_address) like lower(:search) or lower(tr_customer_reference) like lower(:search) or lower(promo_code) like lower(:search) or lower(special_price_token) like lower(:search))) "; + String APPLY_FILTER_SUBSCRIPTION = " (:search is null or (lower(tr_id) like lower(:search) or lower(s_id::text) like lower(:search) or lower(s_first_name) like lower(:search) or lower(s_last_name) like lower(:search) or lower(s_email_address) like lower(:search) " + + " or lower(tr_first_name) like lower(:search) or lower(tr_last_name) like lower(:search) or lower(tr_email_address) like lower(:search) or lower(tr_customer_reference) like lower(:search) or lower(promo_code) like lower(:search) )) "; + String FIND_ALL_MODIFIED_TICKETS_WITH_RESERVATION_AND_TRANSACTION = "select * from reservation_and_ticket_and_tx where t_id is not null and t_status in ('PENDING', 'ACQUIRED', 'TO_BE_PAID', 'CANCELLED', 'CHECKED_IN') and t_category_id = :categoryId and t_event_id = :eventId and " + APPLY_FILTER; String FIND_ALL_CONFIRMED_TICKETS_FOR_EVENT = "select * from reservation_and_ticket_and_tx where t_id is not null and t_status in ('ACQUIRED', 'TO_BE_PAID', 'CHECKED_IN') and t_event_id = :eventId and " + APPLY_FILTER; String FIND_ALL_TICKETS_INCLUDING_NEW = "select * from reservation_and_ticket_and_tx where tr_event_id = :eventId and tr_id is not null and tr_status in (:status) and " + APPLY_FILTER; + String FIND_ALL_SUBSCRIPTION_INCLUDING_NEW = "select * from reservation_and_subscription_and_tx where s_descriptor_id = :subscriptionDescriptorId::uuid and tr_id is not null and tr_status in (:status) and " + APPLY_FILTER_SUBSCRIPTION; + String RESERVATION_FIELDS = "tr_id id, tr_validity validity, tr_status status, tr_full_name full_name, tr_first_name first_name, tr_last_name last_name, tr_email_address email_address," + "tr_billing_address billing_address, tr_confirmation_ts confirmation_ts, tr_latest_reminder_ts latest_reminder_ts, tr_payment_method payment_method," + "tr_offline_payment_reminder_sent offline_payment_reminder_sent, tr_promo_code_id_fk promo_code_id_fk, tr_automatic automatic," + @@ -83,6 +89,13 @@ List findReservationsForEvent(@Bind("eventId") int eventId, @Bind("search") String search, @Bind("status") List toFilter); + @Query("select distinct "+RESERVATION_FIELDS+" from (" + FIND_ALL_SUBSCRIPTION_INCLUDING_NEW + ") as d_tbl order by tr_confirmation_ts desc nulls last, tr_validity limit :pageSize offset :page") + List findReservationsForSubscription(@Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, + @Bind("page") int page, + @Bind("pageSize") int pageSize, + @Bind("search") String search, + @Bind("status") List toFilter); + @Query("select distinct on(tr_id) "+RESERVATION_SEARCH_FIELD+", "+TRANSACTION_FIELDS+"," +PROMO_CODE_FIELDS+" from reservation_and_ticket_and_tx where tr_event_id = :eventId and tr_id is not null and tr_status = 'OFFLINE_PAYMENT' and bt_reservation_id is not null and bt_status = 'PENDING'") List findOfflineReservationsWithPendingTransaction(@Bind("eventId") int eventId); @@ -97,6 +110,11 @@ Integer countReservationsForEvent(@Bind("eventId") int eventId, @Bind("search") String search, @Bind("status") List toFilter); + @Query("select count(distinct tr_id) from (" + FIND_ALL_SUBSCRIPTION_INCLUDING_NEW +" ) as d_tbl") + Integer countReservationsForSubscription(@Bind("subscriptionDescriptorId") UUID subscriptionDescriptorId, + @Bind("search") String search, + @Bind("status") List toFilter); + @Query("select * from reservation_and_ticket_and_tx where tr_event_id = :eventId and tickets_count > 0 and tr_id in (:reservationIds)") List loadAllReservationsWithTickets(@Bind("eventId") int eventId, @Bind("reservationIds") Collection reservationIds); diff --git a/src/main/java/alfio/repository/system/ConfigurationRepository.java b/src/main/java/alfio/repository/system/ConfigurationRepository.java index df5ba428cd..3184abdbc1 100644 --- a/src/main/java/alfio/repository/system/ConfigurationRepository.java +++ b/src/main/java/alfio/repository/system/ConfigurationRepository.java @@ -170,6 +170,11 @@ List findByTicketCategoryAndKey(@Bind("organizationId") int organ @Query("select c_value::jsonb from configuration where c_key = 'TRANSLATION_OVERRIDE' union all select '{}'::jsonb limit 1") @JSONData Map> getSystemOverrideMessages(); + @Query("select coalesce(jsonb_recursive_merge(a.c_value, b.c_value), '{}'::jsonb) from "+ + "(select c_value::jsonb from configuration where c_key = 'TRANSLATION_OVERRIDE' union all select '{}'::jsonb limit 1) a, "+ + "(select c_value::jsonb from configuration_organization where organization_id_fk = :orgId and c_key = 'TRANSLATION_OVERRIDE' union all select '{}'::jsonb limit 1) b") + @JSONData Map> getOrganizationOverrideMessages(@Bind("orgId") int orgId); + @Query("select coalesce(jsonb_recursive_merge(jsonb_recursive_merge(a.c_value, b.c_value), c.c_value), '{}'::jsonb) from "+ "(select c_value::jsonb from configuration where c_key = 'TRANSLATION_OVERRIDE' union all select '{}'::jsonb limit 1) a, "+ "(select c_value::jsonb from configuration_organization where organization_id_fk = :orgId and c_key = 'TRANSLATION_OVERRIDE' union all select '{}'::jsonb limit 1) b,"+ diff --git a/src/main/java/alfio/util/EventUtil.java b/src/main/java/alfio/util/EventUtil.java index db4e5de4cc..75b06e7d8c 100644 --- a/src/main/java/alfio/util/EventUtil.java +++ b/src/main/java/alfio/util/EventUtil.java @@ -17,7 +17,6 @@ package alfio.util; import alfio.controller.decorator.SaleableTicketCategory; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.metadata.CallLink; @@ -81,7 +80,7 @@ private EventUtil() {} .toFormatter(Locale.ROOT); public static boolean displayWaitingQueueForm(Event event, List categories, ConfigurationManager configurationManager, Predicate noTicketsAvailable) { - var confVal = configurationManager.getFor(List.of(STOP_WAITING_QUEUE_SUBSCRIPTIONS, ENABLE_PRE_REGISTRATION, ENABLE_WAITING_QUEUE), ConfigurationLevel.event(event)); + var confVal = configurationManager.getFor(List.of(STOP_WAITING_QUEUE_SUBSCRIPTIONS, ENABLE_PRE_REGISTRATION, ENABLE_WAITING_QUEUE), event.getConfigurationLevel()); return !confVal.get(STOP_WAITING_QUEUE_SUBSCRIPTIONS).getValueAsBooleanOrDefault() && checkWaitingQueuePreconditions(event, categories, noTicketsAvailable, confVal); } @@ -99,7 +98,7 @@ private static boolean checkWaitingQueuePreconditions(Event event, List categories, ConfigurationManager configurationManager, Predicate noTicketsAvailable) { - var confVal = configurationManager.getFor(List.of(ENABLE_PRE_REGISTRATION, ENABLE_WAITING_QUEUE), ConfigurationLevel.event(event)); + var confVal = configurationManager.getFor(List.of(ENABLE_PRE_REGISTRATION, ENABLE_WAITING_QUEUE), event.getConfigurationLevel()); return checkWaitingQueuePreconditions(event, categories, noTicketsAvailable, confVal); } diff --git a/src/main/java/alfio/util/LocaleUtil.java b/src/main/java/alfio/util/LocaleUtil.java index 8a7c4e3699..b05b409d26 100644 --- a/src/main/java/alfio/util/LocaleUtil.java +++ b/src/main/java/alfio/util/LocaleUtil.java @@ -17,7 +17,7 @@ package alfio.util; import alfio.model.ContentLanguage; -import alfio.model.Event; +import alfio.model.LocalizedContent; import alfio.model.Ticket; import org.apache.commons.lang3.StringUtils; @@ -42,15 +42,15 @@ public static Locale forLanguageTag(String lang) { } } - public static Locale forLanguageTag(String lang, Event event) { + public static Locale forLanguageTag(String lang, LocalizedContent localizedContent) { String cleanedUpLang = StringUtils.trimToEmpty(lang).toLowerCase(Locale.ENGLISH); - var filteredLang = event.getContentLanguages() + var filteredLang = localizedContent.getContentLanguages() .stream() .filter(l -> cleanedUpLang.equalsIgnoreCase(l.getLanguage())) .findFirst() .map(ContentLanguage::getLanguage) //vvv fallback - .orElseGet(() -> event.getContentLanguages().stream().findFirst().map(ContentLanguage::getLanguage).orElse("en")); + .orElseGet(() -> localizedContent.getContentLanguages().stream().findFirst().map(ContentLanguage::getLanguage).orElse("en")); return forLanguageTag(filteredLang); } } diff --git a/src/main/java/alfio/util/PinGenerator.java b/src/main/java/alfio/util/PinGenerator.java index 056c02d769..a7075d177c 100644 --- a/src/main/java/alfio/util/PinGenerator.java +++ b/src/main/java/alfio/util/PinGenerator.java @@ -20,6 +20,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; +import java.math.BigInteger; import java.util.Objects; import java.util.regex.Pattern; @@ -28,42 +29,55 @@ public class PinGenerator { private static final String ALLOWED_CHARS = "ACDEFGHJKLMNPQRTUVWXY34679"; private static final Pattern VALIDATION_PATTERN = Pattern.compile("^["+ALLOWED_CHARS+"]+$"); - static final int UUID_PORTION_LENGTH = 7; private static final int PIN_LENGTH = 6; - public static String uuidToPin(String uuid) { - long src = Long.parseLong(uuid.replace("-", "").substring(0, UUID_PORTION_LENGTH), 16); - long chars = ALLOWED_CHARS.length(); + + public static String uuidToPin(String uuid, int pinLength) { + var src = new BigInteger(uuid.replace("-", "").substring(0, pinLength+1), 16); + var chars = BigInteger.valueOf(ALLOWED_CHARS.length()); var pin = new StringBuilder(); do { - long remainder = src % chars; - pin.append(ALLOWED_CHARS.charAt((int)remainder)); - src /= chars; - } while (src != 0); + var remainder = src.mod(chars); + pin.append(ALLOWED_CHARS.charAt(remainder.intValue())); + src = src.divide(chars); + } while (!src.equals(BigInteger.ZERO)); - while(pin.length() < PIN_LENGTH) { + while(pin.length() < pinLength) { pin.append(ALLOWED_CHARS.charAt(0)); } return pin.reverse().toString(); } - public static String pinToPartialUuid(String pin) { - Assert.isTrue(isPinValid(pin), "the given PIN is not valid"); + public static String pinToPartialUuid(String pin, int pinLength) { + Assert.isTrue(isPinValid(pin, pinLength), "the given PIN is not valid"); var uppercasePin = Objects.requireNonNull(pin).strip().toUpperCase(); - long base = ALLOWED_CHARS.length(); - long num = 0; + var base = BigInteger.valueOf(ALLOWED_CHARS.length()); + var num = BigInteger.ZERO; for (int i = 0; i < pin.length(); i++) { char c = uppercasePin.charAt(pin.length() - 1 - i); - num += (long) ALLOWED_CHARS.indexOf(c) * (long) Math.pow(base, i); + var toAdd = BigInteger.valueOf(ALLOWED_CHARS.indexOf(c)).multiply(base.pow(i)); + num = num.add(toAdd); } - return StringUtils.leftPad(Long.toHexString(num), UUID_PORTION_LENGTH, '0'); + return StringUtils.leftPad(num.toString(16), pinLength+1, '0'); } - public static boolean isPinValid(String pin) { + public static boolean isPinValid(String pin, int pinLength) { return pin != null - && pin.strip().length() == PIN_LENGTH + && (pin.strip().length() == pinLength || pin.strip().length() == pinLength + 1) && VALIDATION_PATTERN.matcher(pin.toUpperCase()).matches(); } + public static String uuidToPin(String uuid) { + return uuidToPin(uuid, PIN_LENGTH); + } + + public static String pinToPartialUuid(String pin) { + return pinToPartialUuid(pin, PIN_LENGTH); + } + + public static boolean isPinValid(String pin) { + return isPinValid(pin, PIN_LENGTH); + } + } diff --git a/src/main/java/alfio/util/TemplateManager.java b/src/main/java/alfio/util/TemplateManager.java index ee08c959ec..e95a032287 100644 --- a/src/main/java/alfio/util/TemplateManager.java +++ b/src/main/java/alfio/util/TemplateManager.java @@ -18,9 +18,9 @@ import alfio.manager.UploadedResourceManager; import alfio.manager.i18n.MessageSourceManager; -import alfio.manager.system.ConfigurationLevel; import alfio.manager.system.ConfigurationManager; -import alfio.model.EventAndOrganizationId; +import alfio.model.Event; +import alfio.model.PurchaseContext; import alfio.model.system.ConfigurationKeys; import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Mustache.Compiler; @@ -97,30 +97,30 @@ private static String dateFormatter(Object o) { } - private RenderedTemplate renderMultipartTemplate(EventAndOrganizationId event, TemplateResource templateResource, Map model, Locale locale) { - var enrichedModel = modelEnricher(model, Optional.of(event), locale); + private RenderedTemplate renderMultipartTemplate(PurchaseContext purchaseContext, TemplateResource templateResource, Map model, Locale locale) { + var enrichedModel = modelEnricher(model, purchaseContext, locale); var isMultipart = templateResource.isMultipart(); - var textRender = render(new ClassPathResource(templateResource.classPath()), enrichedModel, locale, event, isMultipart ? TemplateOutput.TEXT : templateResource.getTemplateOutput()); + var textRender = render(new ClassPathResource(templateResource.classPath()), enrichedModel, locale, purchaseContext, isMultipart ? TemplateOutput.TEXT : templateResource.getTemplateOutput()); - boolean htmlEnabled = configurationManager.getFor(ConfigurationKeys.ENABLE_HTML_EMAILS, ConfigurationLevel.event(event)).getValueAsBooleanOrDefault(); + boolean htmlEnabled = configurationManager.getFor(ConfigurationKeys.ENABLE_HTML_EMAILS, purchaseContext.getConfigurationLevel()).getValueAsBooleanOrDefault(); var htmlRender = isMultipart && htmlEnabled ? - render(new ClassPathResource(templateResource.htmlClassPath()), enrichedModel, locale, event, TemplateOutput.HTML) : + render(new ClassPathResource(templateResource.htmlClassPath()), enrichedModel, locale, purchaseContext, TemplateOutput.HTML) : null; return RenderedTemplate.multipart(textRender, htmlRender); } - public RenderedTemplate renderTemplate(EventAndOrganizationId event, TemplateResource templateResource, Map model, Locale locale) { - Map updatedModel = modelEnricher(model, Optional.of(event), locale); - return uploadedResourceManager.findCascading(event.getOrganizationId(), event.getId(), templateResource.getSavedName(locale)) - .map(resource -> RenderedTemplate.plaintext(render(new ByteArrayResource(resource), updatedModel, locale, event, templateResource.getTemplateOutput()))) - .orElseGet(() -> renderMultipartTemplate(event, templateResource, updatedModel, locale)); + public RenderedTemplate renderTemplate(PurchaseContext purchaseContext, TemplateResource templateResource, Map model, Locale locale) { + Map updatedModel = modelEnricher(model, purchaseContext, locale); + return uploadedResourceManager.findCascading(purchaseContext.getOrganizationId(), purchaseContext.event().map(Event::getId).orElse(null), templateResource.getSavedName(locale)) + .map(resource -> RenderedTemplate.plaintext(render(new ByteArrayResource(resource), updatedModel, locale, purchaseContext, templateResource.getTemplateOutput()))) + .orElseGet(() -> renderMultipartTemplate(purchaseContext, templateResource, updatedModel, locale)); } - public String renderString(EventAndOrganizationId event, String template, Map model, Locale locale, TemplateOutput templateOutput) { - return render(new ByteArrayResource(template.getBytes(StandardCharsets.UTF_8)), modelEnricher(model, Optional.ofNullable(event), locale), locale, event, templateOutput); + public String renderString(PurchaseContext purchaseContext, String template, Map model, Locale locale, TemplateOutput templateOutput) { + return render(new ByteArrayResource(template.getBytes(StandardCharsets.UTF_8)), modelEnricher(model, purchaseContext, locale), locale, purchaseContext, templateOutput); } public void renderHtml(Resource resource, Map model, OutputStream os) { @@ -131,19 +131,19 @@ public void renderHtml(Resource resource, Map model, OutputStrea } } - private Map modelEnricher(Map model, Optional event, Locale locale) { + private Map modelEnricher(Map model, PurchaseContext purchaseContext, Locale locale) { Map toEnrich = new HashMap<>(model); - event.ifPresent(ev -> toEnrich.put(VAT_TRANSLATION_TEMPLATE_KEY, messageSourceManager.getMessageSourceForEvent(ev).getMessage("common.vat", null, locale))); + toEnrich.put(VAT_TRANSLATION_TEMPLATE_KEY, messageSourceManager.getMessageSourceFor(purchaseContext).getMessage("common.vat", null, locale)); return toEnrich; } - private String render(Resource resource, Map model, Locale locale, EventAndOrganizationId eventAndOrganizationId, TemplateOutput templateOutput) { + private String render(Resource resource, Map model, Locale locale, PurchaseContext purchaseContext, TemplateOutput templateOutput) { try { ModelAndView mv = new ModelAndView((String) null, model); mv.addObject("format-date", MustacheCustomTag.FORMAT_DATE); mv.addObject("country-name", COUNTRY_NAME); mv.addObject("additional-field-value", ADDITIONAL_FIELD_VALUE.apply(model.get("additional-fields"))); - mv.addObject("i18n", new CustomLocalizationMessageInterceptor(locale, messageSourceManager.getMessageSourceForEvent(eventAndOrganizationId)).createTranslator()); + mv.addObject("i18n", new CustomLocalizationMessageInterceptor(locale, messageSourceManager.getMessageSourceFor(purchaseContext)).createTranslator()); var updatedModel = mv.getModel(); updatedModel.putIfAbsent("custom-header-text", ""); updatedModel.putIfAbsent("custom-body-text", ""); diff --git a/src/main/java/alfio/util/TemplateResource.java b/src/main/java/alfio/util/TemplateResource.java index 995e642b29..a1455c64dc 100644 --- a/src/main/java/alfio/util/TemplateResource.java +++ b/src/main/java/alfio/util/TemplateResource.java @@ -60,6 +60,12 @@ public Map prepareSampleModel(Organization organization, Event e return prepareSampleDataForConfirmationEmail(organization, event); } }, + CONFIRMATION_EMAIL_SUBSCRIPTION("/alfio/templates/confirmation-email-subscription", TemplateResource.MULTIPART_ALTERNATIVE_MIMETYPE, TemplateManager.TemplateOutput.TEXT) { + @Override + public Map prepareSampleModel(Organization organization, Event event, Optional imageData) { + return prepareSampleDataForConfirmationEmail(organization, event); + } + }, OFFLINE_RESERVATION_EXPIRED_EMAIL("/alfio/templates/offline-reservation-expired-email-txt.ms", "text/plain", TemplateManager.TemplateOutput.TEXT) { @Override public Map prepareSampleModel(Organization organization, Event event, Optional imageData) { @@ -333,7 +339,7 @@ private static Map prepareSampleDataForChargeFailed(Organization // - RECEIPT_PDF + ImageData // - INVOICE_PDF + ImageData public static Map prepareModelForConfirmationEmail(Organization organization, - Event event, + PurchaseContext purchaseContext, TicketReservation reservation, Optional vat, List tickets, @@ -347,7 +353,9 @@ public static Map prepareModelForConfirmationEmail(Organization Map additionalModelObjects) { Map model = new HashMap<>(additionalModelObjects); model.put("organization", organization); - model.put("event", event); + model.put("event", purchaseContext.event().orElse(null)); + model.put("purchaseContext", purchaseContext); + model.put("purchaseContextTitle", purchaseContext.getTitle().get(reservation.getUserLanguage())); model.put("ticketReservation", reservation); model.put("hasVat", vat.isPresent()); model.put("vatNr", vat.orElse("")); @@ -359,13 +367,14 @@ public static Map prepareModelForConfirmationEmail(Organization model.put("hasRefund", StringUtils.isNotEmpty(orderSummary.getRefundedAmount())); - var clock = ClockProvider.clock().withZone(event.getZoneId()); + var zoneId = purchaseContext.getZoneId(); + var clock = ClockProvider.clock().withZone(zoneId); ZonedDateTime creationTimestamp = ObjectUtils.firstNonNull(reservation.getRegistrationTimestamp(), reservation.getConfirmationTimestamp(), reservation.getCreationTimestamp(), ZonedDateTime.now(clock)); - model.put("confirmationDate", creationTimestamp.withZoneSameInstant(event.getZoneId())); + model.put("confirmationDate", creationTimestamp.withZoneSameInstant(zoneId)); model.put("now", ZonedDateTime.now(clock)); if (reservation.getValidity() != null) { - model.put("expirationDate", ZonedDateTime.ofInstant(reservation.getValidity().toInstant(), event.getZoneId())); + model.put("expirationDate", ZonedDateTime.ofInstant(reservation.getValidity().toInstant(), zoneId)); } model.put("reservationShortID", reservationShortID); @@ -381,7 +390,7 @@ public static Map prepareModelForConfirmationEmail(Organization model.put("isOfflinePayment", reservation.getStatus() == TicketReservation.TicketReservationStatus.OFFLINE_PAYMENT); model.put("hasCustomerReference", StringUtils.isNotBlank(reservation.getCustomerReference())); - model.put("paymentReason", event.getShortName() + " " + reservationShortID); + model.put("paymentReason", reservationShortID); model.put("hasBankAccountOnwer", bankAccountOwner.isPresent()); bankAccountOwner.ifPresent(owner -> { model.put("bankAccountOnwer", StringUtils.replace(owner, "\n", ", ")); diff --git a/src/main/resources/alfio/db/PGSQL/V203_2.0.0.25__SUBSCRIPTION_SUPPORT.sql b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.25__SUBSCRIPTION_SUPPORT.sql new file mode 100644 index 0000000000..db0265e65a --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.25__SUBSCRIPTION_SUPPORT.sql @@ -0,0 +1,117 @@ +-- +-- 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 type SUBSCRIPTION_TIME_UNIT as enum ('DAYS', 'MONTHS', 'YEARS'); +create type SUBSCRIPTION_VALIDITY_TYPE as enum ('STANDARD', 'CUSTOM', 'NOT_SET'); +create type SUBSCRIPTION_USAGE_TYPE as enum ('ONCE_PER_EVENT', 'UNLIMITED'); +create type ALLOCATION_STATUS as enum ('FREE', 'PRE_RESERVED', 'PENDING', 'TO_BE_PAID', 'ACQUIRED', 'CANCELLED', + 'CHECKED_IN', 'EXPIRED', + 'INVALIDATED', 'RELEASED'); + +create type VAT_STATUS as enum( + 'NONE', 'INCLUDED', 'NOT_INCLUDED', + 'INCLUDED_EXEMPT', 'NOT_INCLUDED_EXEMPT'); + +create table subscription_descriptor ( + id uuid primary key not null, + title jsonb not null, + description jsonb, + max_available integer not null default -1, + creation_ts timestamp with time zone not null default now(), + on_sale_from timestamp with time zone not null, + on_sale_to timestamp with time zone, + price_cts integer not null, + vat decimal(5,2) not null, + vat_status VAT_STATUS not null check (vat_status in('NONE', 'INCLUDED', 'NOT_INCLUDED')), + currency text, + is_public boolean not null default false, + organization_id_fk integer not null constraint subscription_descriptor_organization_id_fk references organization(id), + + -- subscription template + max_entries integer not null default -1, + validity_type SUBSCRIPTION_VALIDITY_TYPE not null, + validity_time_unit SUBSCRIPTION_TIME_UNIT check (validity_type <> 'STANDARD' OR validity_time_unit is not null), + validity_units integer check (validity_type <> 'STANDARD' OR (validity_units is not null AND validity_units > 0)), + validity_from timestamp with time zone check (validity_type <> 'CUSTOM' OR validity_from is not null), + validity_to timestamp with time zone, + usage_type SUBSCRIPTION_USAGE_TYPE not null default 'ONCE_PER_EVENT', + + terms_conditions_url text not null, + privacy_policy_url text, + file_blob_id_fk char(64) not null constraint subscription_descriptor_file_blob_id references file_blob(id), + allowed_payment_proxies text array not null, + private_key text not null, + time_zone text not null + +); + +alter table subscription_descriptor enable row level security; +alter table subscription_descriptor force row level security; +create policy subscription_descriptor_access_policy on subscription_descriptor to public + using (alfio_check_row_access(organization_id_fk)) + with check (alfio_check_row_access(organization_id_fk)); + +create table subscription ( + id uuid primary key not null, + first_name text, + last_name text, + email_address text, + code text not null constraint subscription_code_unique unique, + subscription_descriptor_fk uuid not null constraint subscription_subscription_descriptor_fk references subscription_descriptor(id), + reservation_id_fk character(36) not null constraint subscription_reservation_id_fk references tickets_reservation(id), + usage_count integer not null, + max_usage integer, + valid_from timestamp with time zone default now(), + valid_to timestamp with time zone, + src_price_cts integer not null default 0, + final_price_cts integer not null default 0, + vat_cts integer not null default 0, + discount_cts integer not null default 0, + currency text, + organization_id_fk integer not null constraint subscription_organization_id_fk references organization(id), + creation_ts timestamp with time zone not null default now(), + update_ts timestamp with time zone, + status ALLOCATION_STATUS not null default 'FREE' +); + +alter table subscription enable row level security; +alter table subscription force row level security; +create policy subscription_access_policy on subscription to public + using (alfio_check_row_access(organization_id_fk)) + with check (alfio_check_row_access((organization_id_fk))); + +create table subscription_event ( + id bigserial primary key not null, + event_id_fk integer not null references event(id), + subscription_descriptor_id_fk uuid not null constraint subscription_event_subscription_descriptor_id_fk references subscription_descriptor(id), + price_per_ticket integer not null default 0, + organization_id_fk integer not null constraint subscription_event_organization_id_fk references organization(id) +); + +alter table subscription_event add constraint "unique_subscription_event" unique(subscription_descriptor_id_fk, event_id_fk); + +alter table subscription_event enable row level security; +alter table subscription_event force row level security; +create policy subscription_event_access_policy on subscription_event to public + using (alfio_check_row_access(organization_id_fk)) + with check (alfio_check_row_access((organization_id_fk))); + +alter table event add column tags text array not null default array[]::text[]; +alter table tickets_reservation add column subscription_id_fk uuid constraint ticket_subscription_id_fk references subscription(id); + + +alter table tickets_reservation alter column event_id_fk drop not null; \ No newline at end of file diff --git a/src/main/resources/alfio/db/PGSQL/V203_2.0.0.26__SUBSCRIPTION_REMOVE_CODE.sql b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.26__SUBSCRIPTION_REMOVE_CODE.sql new file mode 100644 index 0000000000..66d1d2142a --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.26__SUBSCRIPTION_REMOVE_CODE.sql @@ -0,0 +1,19 @@ +-- +-- 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 subscription drop column code; +create index subscription_partial_id_idx on subscription ( substring(replace(id::text,'-',''), 0, 11) ); \ No newline at end of file diff --git a/src/main/resources/alfio/db/PGSQL/V203_2.0.0.27__SUBSCRIPTION_BILLING_DOCUMENT.sql b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.27__SUBSCRIPTION_BILLING_DOCUMENT.sql new file mode 100644 index 0000000000..54daa9f4c3 --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.27__SUBSCRIPTION_BILLING_DOCUMENT.sql @@ -0,0 +1,18 @@ +-- +-- 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 billing_document alter column event_id_fk drop not null; diff --git a/src/main/resources/alfio/db/PGSQL/V203_2.0.0.28__SUBSCRIPTION_EMAIL_MESSAGE.sql b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.28__SUBSCRIPTION_EMAIL_MESSAGE.sql new file mode 100644 index 0000000000..3a3c76cd7a --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.28__SUBSCRIPTION_EMAIL_MESSAGE.sql @@ -0,0 +1,27 @@ +-- +-- 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 email_message alter column event_id drop not null, + add column subscription_descriptor_id_fk uuid constraint "email_message_subscription_descriptor_fk" references subscription_descriptor(id); + +-- drop and recreate (partial) index on event_id +drop index idx_email_event_id; +create index idx_email_event_id on email_message(event_id) where event_id is not null; +-- create partial index on subscription_descriptor_id_fk +create index idx_email_subscription_descriptor on email_message(subscription_descriptor_id_fk) where subscription_descriptor_id_fk is not null; +-- create partial index on status +create index idx_email_subscription_status_process on email_message(status) where status = 'WAITING' or status = 'RETRY'; diff --git a/src/main/resources/alfio/db/PGSQL/V203_2.0.0.29__SUBSCRIPTION_CONSTRAINTS.sql b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.29__SUBSCRIPTION_CONSTRAINTS.sql new file mode 100644 index 0000000000..6b0ede1e6f --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/V203_2.0.0.29__SUBSCRIPTION_CONSTRAINTS.sql @@ -0,0 +1,19 @@ +-- +-- 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 subscription alter column reservation_id_fk drop not null, + add constraint "reservation_required_for_advanced_status" check (status < 'PRE_RESERVED' or status > 'EXPIRED' or reservation_id_fk 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 52be9b1987..ccd8e0e38d 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 @@ -24,4 +24,7 @@ drop view if exists latest_ticket_update; drop view if exists reservation_and_ticket_and_tx; drop view if exists ticket_category_with_currency; drop view if exists additional_service_with_currency; -drop view if exists checkin_ticket_event_and_category_info; \ No newline at end of file +drop view if exists checkin_ticket_event_and_category_info; +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; \ No newline at end of file diff --git a/src/main/resources/alfio/db/PGSQL/afterMigrate__010_VIEW_reservation_and_subscription_and_tx.sql b/src/main/resources/alfio/db/PGSQL/afterMigrate__010_VIEW_reservation_and_subscription_and_tx.sql new file mode 100644 index 0000000000..3ca68b67ff --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/afterMigrate__010_VIEW_reservation_and_subscription_and_tx.sql @@ -0,0 +1,99 @@ +-- +-- 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 reservation_and_subscription_and_tx as (select + + tickets_reservation.id tr_id, + tickets_reservation.validity tr_validity, + tickets_reservation.status tr_status, + tickets_reservation.full_name tr_full_name, + tickets_reservation.first_name tr_first_name, + tickets_reservation.last_name tr_last_name, + tickets_reservation.email_address tr_email_address, + tickets_reservation.billing_address tr_billing_address, + tickets_reservation.confirmation_ts tr_confirmation_ts, + tickets_reservation.latest_reminder_ts tr_latest_reminder_ts, + tickets_reservation.payment_method tr_payment_method, + tickets_reservation.offline_payment_reminder_sent tr_offline_payment_reminder_sent, + tickets_reservation.promo_code_id_fk tr_promo_code_id_fk, + tickets_reservation.automatic tr_automatic, + tickets_reservation.user_language tr_user_language, + tickets_reservation.direct_assignment tr_direct_assignment, + tickets_reservation.invoice_number tr_invoice_number, + tickets_reservation.invoice_model tr_invoice_model, + tickets_reservation.vat_status tr_vat_status, + tickets_reservation.vat_nr tr_vat_nr, + tickets_reservation.vat_country tr_vat_country, + tickets_reservation.invoice_requested tr_invoice_requested, + tickets_reservation.used_vat_percent tr_used_vat_percent, + tickets_reservation.vat_included tr_vat_included, + tickets_reservation.creation_ts tr_creation_ts, + tickets_reservation.customer_reference tr_customer_reference, + tickets_reservation.billing_address_company tr_billing_address_company, + tickets_reservation.billing_address_line1 tr_billing_address_line1, + tickets_reservation.billing_address_line2 tr_billing_address_line2, + tickets_reservation.billing_address_city tr_billing_address_city, + tickets_reservation.billing_address_zip tr_billing_address_zip, + tickets_reservation.registration_ts tr_registration_ts, + tickets_reservation.invoicing_additional_information tr_invoicing_additional_information, + + tickets_reservation.src_price_cts tr_src_price_cts, + tickets_reservation.final_price_cts tr_final_price_cts, + tickets_reservation.vat_cts tr_vat_cts, + tickets_reservation.discount_cts tr_discount_cts, + tickets_reservation.currency_code tr_currency_code, + + subscription.id s_id, + subscription.first_name s_first_name, + subscription.last_name s_last_name, + subscription.email_address s_email_address, + subscription.usage_count s_usage_count, + subscription.max_usage s_max_usage, + subscription.valid_from s_valid_from, + subscription.valid_to s_valid_to, + subscription.src_price_cts s_src_price_cts, + subscription.final_price_cts s_final_price_cts, + subscription.vat_cts s_vat_cts, + subscription.discount_cts s_discount_cts, + subscription.currency s_currency, + subscription.organization_id_fk s_organization_id, + subscription.creation_ts s_creation_ts, + subscription.update_ts s_update_ts, + subscription.status s_status, + subscription.subscription_descriptor_fk s_descriptor_id, + + b_transaction.id bt_id, + b_transaction.gtw_tx_id bt_gtw_tx_id, + b_transaction.gtw_payment_id bt_gtw_payment_id, + b_transaction.reservation_id bt_reservation_id, + b_transaction.t_timestamp bt_t_timestamp, + b_transaction.price_cts bt_price_cts, + b_transaction.currency bt_currency, + b_transaction.description bt_description, + b_transaction.payment_proxy bt_payment_proxy, + b_transaction.gtw_fee bt_gtw_fee, + b_transaction.plat_fee bt_plat_fee, + b_transaction.status bt_status, + b_transaction.metadata bt_metadata, + + promo_code.promo_code as promo_code + +from tickets_reservation +join subscription on subscription.reservation_id_fk = tickets_reservation.id +left outer join b_transaction on tickets_reservation.id = b_transaction.reservation_id and b_transaction.status <> 'INVALID' +left outer join promo_code on tickets_reservation.promo_code_id_fk = promo_code.id +); \ No newline at end of file diff --git a/src/main/resources/alfio/db/PGSQL/afterMigrate__011_VIEW_basic_event_with_optional_subscription.sql b/src/main/resources/alfio/db/PGSQL/afterMigrate__011_VIEW_basic_event_with_optional_subscription.sql new file mode 100644 index 0000000000..7030f5d48f --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/afterMigrate__011_VIEW_basic_event_with_optional_subscription.sql @@ -0,0 +1,25 @@ +-- +-- 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 basic_event_with_optional_subscription as ( + select e.*, + s.id as subscription_id + from event e + left join subscription_event se on se.event_id_fk = e.id + left join subscription_descriptor sd on se.subscription_descriptor_id_fk = sd.id + left join subscription s on sd.id = s.subscription_descriptor_fk +); diff --git a/src/main/resources/alfio/db/PGSQL/afterMigrate__012_VIEW_subscription_descriptor_statistics.sql b/src/main/resources/alfio/db/PGSQL/afterMigrate__012_VIEW_subscription_descriptor_statistics.sql new file mode 100644 index 0000000000..117d8bc22b --- /dev/null +++ b/src/main/resources/alfio/db/PGSQL/afterMigrate__012_VIEW_subscription_descriptor_statistics.sql @@ -0,0 +1,50 @@ +-- +-- 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 subscription_descriptor_statistics as ( + select + sd.id sd_id, + sd.title sd_title, + sd.description sd_description, + sd.max_available sd_max_available, + sd.creation_ts sd_creation_ts, + sd.on_sale_from sd_on_sale_from, + sd.on_sale_to sd_on_sale_to, + sd.price_cts sd_price_cts, + sd.vat sd_vat, + sd.vat_status sd_vat_status, + sd.currency sd_currency, + sd.is_public sd_is_public, + sd.organization_id_fk sd_organization_id_fk, + sd.max_entries sd_max_entries, + sd.validity_type sd_validity_type, + sd.validity_time_unit sd_validity_time_unit, + sd.validity_units sd_validity_units, + sd.validity_from sd_validity_from, + sd.validity_to sd_validity_to, + sd.usage_type sd_usage_type, + sd.terms_conditions_url sd_terms_conditions_url, + sd.privacy_policy_url sd_privacy_policy_url, + sd.file_blob_id_fk sd_file_blob_id_fk, + sd.allowed_payment_proxies sd_allowed_payment_proxies, + sd.private_key sd_private_key, + sd.time_zone sd_time_zone, + (select count(*) from reservation_and_subscription_and_tx where s_descriptor_id = sd.id) s_reservations_count, + (select count(*) from subscription where status between 'ACQUIRED' and 'CHECKED_IN' and subscription_descriptor_fk = sd.id) s_sold_count, + (select count(*) from subscription_event where subscription_descriptor_id_fk = sd.id) s_events_count + from subscription_descriptor sd +) \ No newline at end of file diff --git a/src/main/resources/alfio/i18n/public.properties b/src/main/resources/alfio/i18n/public.properties index 333d080920..fc664cab95 100644 --- a/src/main/resources/alfio/i18n/public.properties +++ b/src/main/resources/alfio/i18n/public.properties @@ -32,6 +32,7 @@ session-expired.header.title=Session expired #show-event.ms show-event.header.title=Reserve a Ticket for {0}\! +show-subscription.header.title=Buy a {0} subscription show-event.tickets.left={0} left show-event.category.quantity=Quantity show-event.by=By @@ -272,8 +273,10 @@ email.ticket-email-sent=The ticket has been sent by email. #email +purchase-context.event=Event +purchase-context.subscription=Subscription ticket-email-subject=Your ticket for event {0} -reservation-email-subject=Your reservation n. {0} for event {1} +reservation-email-subject=Your reservation n. {0} for {2} {1} reservation-email-expired-subject=Your reservation n. {0} for event {1} has been cancelled ticket-has-changed-owner-subject=Notice: your ticket for event {0} has changed email owner @@ -285,12 +288,15 @@ email.event-reminder-date=on: email.kind-regards=Kind regards, email-ticket.attached=Attached to this email you will find the ticket for the event: {0}. email-confirmation.completed=Your reservation for the event {0} has been completed. You can modify/review it at {1} +email-confirmation.subscription.completed=Thank you for registering! Your reservation is now confirmed. email-confirmation.waiting-for-payment=Your order for the event {0} has been placed. Please follow the payment instructions at {1} +email-confirmation.subscription.waiting-for-payment=Your order for has been placed. Please follow the payment instructions at {0} email-confirmation.reservation-summary=Reservation summary email-confirmation.summary.category=Category email-confirmation.summary.quantity=Quantity email-confirmation.summary.subtotal=Subtotal email-confirmation.order-information=Order information +email-confirmation.view-reservation=View Reservation email-confirmation.vatNr={0} number email-confirmation.reservationURL=You can modify/review your Ticket details at {0} email-ticket.add-to-calendar=You can save the event in your google calendar, click on the following link: {0} @@ -318,7 +324,7 @@ email-transaction-failed.retry=You can safely retry by opening the reservation p email-transaction-failed.cancelled=The reservation has been cancelled. Please get in touch with the organizers if you have any questions. email-transaction-failed.subject=Payment failed for reservation n. {0} -breadcrumb.step1=Tickets +breadcrumb.step1=Selection breadcrumb.step2=Contact Details breadcrumb.step3=Payment breadcrumb.step3.free=Overview @@ -473,4 +479,60 @@ poll.title=Poll poll.page.title={0}: Poll poll.enter-your-pin=Enter your PIN poll.select=Select poll -pin.invalid=Invalid PIN \ No newline at end of file +pin.invalid=Invalid PIN + + +# home +home.view.all.events=View all events +home.view.all.subscriptions=View all subscriptions +home.header.title= +subscription.title=Subscriptions +subscription-list.link=Details +# subscription +subscription.header.title=Choose your subscription +subscription.no-subscriptions=No subscriptions found +subscription.detail.info=Subscription Detail +subscription.detail.pricing=Pricing +subscription.detail.buy=Buy + +subscription.detail.validity.NOT_SET.description=Valid for {0} entries. {1} +subscription.usage-type.ONCE_PER_EVENT=One access per Event +subscription.usage-type.UNLIMITED=Multiple accesses per Event + +subscription.detail.validity.STANDARD.description=Valid for {0} {1}. {2} +subscription.time-unit.DAYS=days +subscription.time-unit.MONTHS=months +subscription.time-unit.YEARS=years + +subscription.detail.validity.CUSTOM.from=Valid from +subscription.detail.validity.CUSTOM.to=to + +common.back-to-organizer=Back to organizer''s site + +summary.table.subscription=Subscription: {0} +summary.table.remove-applied-subscription-from-order=Remove applied subscription from order +reservation-page.subscription=Subscription +reservation-page.overview.insert-subscription-code=Insert your subscription code +reservation-page.overview.pay-with-subscription=Register using your subscription code +reservation-page.overview.apply-subscription-code=Apply code +reservation-page.overview.applied-subscription-code=Subscription code has been successfully applied. +reservation-page.overview.removed-subscription=Subscription has been successfully removed. +reservation-page.overview.modal.remove-subscription=Remove applied subscription? +reservation-page-complete.subscription=Subscription details +reservation-page-complete.subscription.pin-description=Please use the following PIN to register +reservation-page-complete.subscription.id-description=If requested, use the complete ID +reservation-page-complete.subscription.copy-pin=Copy PIN to the Clipboard +reservation-page-complete.subscription.copy-id=Copy ID to the Clipboard +reservation-page-complete.subscription.copy.success=Code successfully copied! +reservation-page-complete.subscription.buy-tickets=You can use your subscription with the following events: +reservation-page-complete.buy-tickets=Buy tickets +reservation-page-complete.info-subscription=Great\! Your subscription is now complete. +reservation-page-complete.info-subscription-email=P.S.\: We sent you ({0}) a confirmation email. If you haven''t received any message, please click on the following button to send it again + +subscription.pin.not.found=Subscription with PIN "{0}" not found. +subscription.not.acquired=Subscription has not been acquired. +subscription.uuid.not.found=Subscription with complete ID "{0}" not found. +subscription.code.insert.full=Please, insert the "complete ID" of your subscription. +subscription.max-usage-reached=You have reached the usage limit of your subscription. + +common.close=Close \ No newline at end of file diff --git a/src/main/resources/alfio/mjml/confirmation-email-for-organizer-html.mjml b/src/main/resources/alfio/mjml/confirmation-email-for-organizer-html.mjml index bcad072fe9..9ee0e23876 100644 --- a/src/main/resources/alfio/mjml/confirmation-email-for-organizer-html.mjml +++ b/src/main/resources/alfio/mjml/confirmation-email-for-organizer-html.mjml @@ -27,7 +27,7 @@ - {{ticketReservation.fullName}}<{{ticketReservation.email}}> has completed the reservation {{reservationShortID}} for event {{event.displayName}} + {{ticketReservation.fullName}}<{{ticketReservation.email}}> has completed the reservation {{reservationShortID}} for {{purchaseContext.displayName}} @@ -41,7 +41,7 @@ {{name}} {{amount}} {{ticketReservation.paymentMethod}} - {{subTotal}} {{event.currency}} + {{subTotal}} {{purchaseContext.currency}} {{/orderSummary.summary}} @@ -51,13 +51,13 @@ VAT {{ticketReservation.usedVatPercent}}% - {{orderSummary.totalVAT}} {{event.currency}} + {{orderSummary.totalVAT}} {{purchaseContext.currency}} {{/ticketReservation.vatIncluded}}{{/orderSummary.free}} Total - {{orderSummary.totalPrice}} {{event.currency}} + {{orderSummary.totalPrice}} {{purchaseContext.currency}} {{^orderSummary.displayVat}}(Vat Exempt){{/orderSummary.displayVat}} @@ -65,7 +65,7 @@ VAT INCL {{ticketReservation.usedVatPercent}}% - {{orderSummary.totalVAT}} {{event.currency}} + {{orderSummary.totalVAT}} {{purchaseContext.currency}} {{/ticketReservation.vatIncluded}}{{/orderSummary.free}} diff --git a/src/main/resources/alfio/mjml/confirmation-email-html.mjml b/src/main/resources/alfio/mjml/confirmation-email-html.mjml index 8fd2da6c0a..8412fab029 100644 --- a/src/main/resources/alfio/mjml/confirmation-email-html.mjml +++ b/src/main/resources/alfio/mjml/confirmation-email-html.mjml @@ -12,8 +12,8 @@ @media (prefers-color-scheme: dark) { body { - background: black; - color: #ccc; + background: black; + color: #ccc; } } a { @@ -64,7 +64,7 @@ {{#orderSummary.waitingForPayment}}{{#i18n}}email-confirmation.waiting-for-payment [{{event.displayName}}] [{{reservationUrl}}]{{/i18n}}{{/orderSummary.waitingForPayment}} - View reservation + {{#i18n}}email-confirmation.view-reservation{{/i18n}} diff --git a/src/main/resources/alfio/mjml/confirmation-email-subscription-html.mjml b/src/main/resources/alfio/mjml/confirmation-email-subscription-html.mjml new file mode 100644 index 0000000000..4e188c0832 --- /dev/null +++ b/src/main/resources/alfio/mjml/confirmation-email-subscription-html.mjml @@ -0,0 +1,141 @@ + + + + + + + + body { + background: white; color: #393939; + } + @media (prefers-color-scheme: dark) { + body { + background: black; color: #ccc; + } + } + a { color: #0056b3; text-decoration: none; } + a:hover { color: #0056b3; text-decoration: underline; } + tr { line-height:2; text-align:center; } + tr .bottom-border { border-bottom:2px solid #ecedee; } + + + + + + + + + + + + + + + + + + {{#i18n}}email.hello [{{ticketReservation.fullName}}]{{/i18n}}
{{custom-header-text}} +
+ {{^orderSummary.waitingForPayment}} + {{#i18n}}email-confirmation.subscription.completed{{/i18n}} + {{/orderSummary.waitingForPayment}} + {{#orderSummary.waitingForPayment}} + {{#i18n}}email-confirmation.subscription.waiting-for-payment [{{reservationUrl}}]{{/i18n}} + {{#i18n}}email-confirmation.view-reservation{{/i18n}} + {{/orderSummary.waitingForPayment}} +
+
+ {{^orderSummary.waitingForPayment}} + + + +

{{#i18n}}reservation-page-complete.subscription{{/i18n}}

+
+
+ + {{#i18n}}reservation-page-complete.subscription.pin-description{{/i18n}} + +

{{pin}}

+
+
+ + {{#i18n}}reservation-page-complete.subscription.id-description{{/i18n}} + {{subscriptionId}} + + + + {{#i18n}}reservation-page-complete.buy-tickets{{/i18n}} + + +
+ {{/orderSummary.waitingForPayment}} + + + + + + + {{#i18n}}email-confirmation.reservation-summary{{/i18n}} + + + {{#orderSummary.summary}} + + {{#i18n}}email-confirmation.summary.category{{/i18n}} + {{name}} + + + {{#i18n}}email-confirmation.summary.quantity{{/i18n}} + {{amount}} + + {{^orderSummary.free}} + + {{#i18n}}email-confirmation.summary.subtotal{{/i18n}} + {{subTotal}} {{purchaseContext.currency}} + + {{/orderSummary.free}} + {{/orderSummary.summary}} + {{^orderSummary.free}}{{^ticketReservation.vatIncluded}} + + {{#i18n}}reservation-page.vat [{{ticketReservation.usedVatPercent}}] [{{vatTranslation}}]{{/i18n}} + {{orderSummary.totalVAT}} {{purchaseContext.currency}} + + {{/ticketReservation.vatIncluded}}{{/orderSummary.free}} + + {{#i18n}}reservation-page.total{{/i18n}} + {{orderSummary.totalPrice}} {{purchaseContext.currency}} + + {{^orderSummary.free}}{{#ticketReservation.vatIncluded}} + + {{#i18n}}reservation-page.vat-included [{{ticketReservation.usedVatPercent}}] [{{vatTranslation}}]{{/i18n}} + {{orderSummary.totalVAT}} {{purchaseContext.currency}} + + {{/ticketReservation.vatIncluded}}{{/orderSummary.free}} + {{^orderSummary.free}}{{#hasVat}} + + {{#i18n}}email-confirmation.vatNr [{{vatTranslation}}]{{/i18n}} + {{vatNr}} + + {{/hasVat}}{{/orderSummary.free}} + + {{#i18n}}email-confirmation.order-information{{/i18n}} + {{ticketReservation.id}} + + + + {{#orderSummary.notYetPaid}}{{#i18n}}reservation.not-yet-paid [{{orderSummary.totalPrice}} {{purchaseContext.currency}}]{{/i18n}}{{/orderSummary.notYetPaid}} +
+ {{custom-body-text}} +
+
+
+ {{#custom-footer-text?}} + + + + {{custom-footer-text}} + + + + {{/custom-footer-text?}} +
+
\ No newline at end of file diff --git a/src/main/resources/alfio/templates/confirmation-email-for-organizer-txt.ms b/src/main/resources/alfio/templates/confirmation-email-for-organizer-txt.ms index 2eeb056eb9..5874e386f2 100644 --- a/src/main/resources/alfio/templates/confirmation-email-for-organizer-txt.ms +++ b/src/main/resources/alfio/templates/confirmation-email-for-organizer-txt.ms @@ -1,13 +1,13 @@ -{{ticketReservation.fullName}}<{{ticketReservation.email}}> has completed the reservation {{reservationShortID}} for event {{event.displayName}} +{{ticketReservation.fullName}}<{{ticketReservation.email}}> has completed the reservation {{reservationShortID}} for {{purchaseContext.displayName}} {{#orderSummary.summary}} -Category: {{name}}, Quantity: {{amount}}, Subtotal: {{subTotal}} {{event.currency}}, Payment Method: {{ticketReservation.paymentMethod}}{{/orderSummary.summary}} +Category: {{name}}, Quantity: {{amount}}, Subtotal: {{subTotal}} {{purchaseContext.currency}}, Payment Method: {{ticketReservation.paymentMethod}}{{/orderSummary.summary}} -{{^orderSummary.free}}{{^ticketReservation.vatIncluded}}VAT {{ticketReservation.usedVatPercent}}%: {{orderSummary.totalVAT}} {{event.currency}}{{/ticketReservation.vatIncluded}}{{/orderSummary.free}} +{{^orderSummary.free}}{{^ticketReservation.vatIncluded}}VAT {{ticketReservation.usedVatPercent}}%: {{orderSummary.totalVAT}} {{purchaseContext.currency}}{{/ticketReservation.vatIncluded}}{{/orderSummary.free}} -Total: {{orderSummary.totalPrice}} {{event.currency}} {{^orderSummary.displayVat}}(Vat Exempt){{/orderSummary.displayVat}} +Total: {{orderSummary.totalPrice}} {{purchaseContext.currency}} {{^orderSummary.displayVat}}(Vat Exempt){{/orderSummary.displayVat}} -{{^orderSummary.free}}{{#ticketReservation.vatIncluded}}VAT INCL {{ticketReservation.usedVatPercent}}%: {{orderSummary.totalVAT}} {{event.currency}}{{/ticketReservation.vatIncluded}}{{/orderSummary.free}} +{{^orderSummary.free}}{{#ticketReservation.vatIncluded}}VAT INCL {{ticketReservation.usedVatPercent}}%: {{orderSummary.totalVAT}} {{purchaseContext.currency}}{{/ticketReservation.vatIncluded}}{{/orderSummary.free}} Reservation id: {{publicId}}. diff --git a/src/main/resources/alfio/templates/confirmation-email-subscription-txt.ms b/src/main/resources/alfio/templates/confirmation-email-subscription-txt.ms new file mode 100644 index 0000000000..1b3257d6ba --- /dev/null +++ b/src/main/resources/alfio/templates/confirmation-email-subscription-txt.ms @@ -0,0 +1,41 @@ +{{purchaseContextTitle}} + +{{#i18n}}email.hello [{{ticketReservation.fullName}}]{{/i18n}} +{{custom-header-text}} +{{^orderSummary.waitingForPayment}}{{#i18n}}email-confirmation.subscription.completed{{/i18n}}{{/orderSummary.waitingForPayment}} +{{#orderSummary.waitingForPayment}}{{#i18n}}email-confirmation.subscription.waiting-for-payment [{{reservationUrl}}]{{/i18n}}{{/orderSummary.waitingForPayment}} + +{{^orderSummary.waitingForPayment}} +#### {{#i18n}}reservation-page-complete.subscription{{/i18n}} #### + +{{#i18n}}reservation-page-complete.subscription.pin-description{{/i18n}} +{{pin}} + +{{#i18n}}reservation-page-complete.subscription.id-description{{/i18n}} +{{subscriptionId}} + +{{#i18n}}reservation-page-complete.buy-tickets{{/i18n}}: {{baseUrl}}/events-all?subscription={{subscriptionId}} +{{/orderSummary.waitingForPayment}} + +#### {{#i18n}}email-confirmation.reservation-summary{{/i18n}} #### + +{{#orderSummary.summary}} +{{#i18n}}email-confirmation.summary.category{{/i18n}}: {{name}}, {{#i18n}}email-confirmation.summary.quantity{{/i18n}}: {{amount}}, {{#i18n}}email-confirmation.summary.subtotal{{/i18n}}: {{subTotal}} {{event.currency}} +{{/orderSummary.summary}} +{{^orderSummary.free}}{{^ticketReservation.vatIncluded}} +{{#i18n}}reservation-page.vat [{{ticketReservation.usedVatPercent}}] [{{vatTranslation}}]{{/i18n}}: {{orderSummary.totalVAT}} {{event.currency}}{{/ticketReservation.vatIncluded}}{{/orderSummary.free}} +{{#i18n}}reservation-page.total{{/i18n}} {{orderSummary.totalPrice}} {{event.currency}} +{{^orderSummary.free}}{{#ticketReservation.vatIncluded}} +{{#i18n}}reservation-page.vat-included [{{ticketReservation.usedVatPercent}}] [{{vatTranslation}}]{{/i18n}}: {{orderSummary.totalVAT}} {{event.currency}}{{/ticketReservation.vatIncluded}}{{/orderSummary.free}} + +{{#orderSummary.notYetPaid}}{{#i18n}}reservation.not-yet-paid [{{orderSummary.totalPrice}} {{event.currency}}]{{/i18n}}{{/orderSummary.notYetPaid}} + +{{^orderSummary.free}}{{#hasVat}}{{#i18n}}email-confirmation.vatNr [{{vatTranslation}}]{{/i18n}}: {{vatNr}}{{/hasVat}}{{/orderSummary.free}} + +{{#i18n}}email-confirmation.order-information{{/i18n}}: {{ticketReservation.id}} +{{custom-body-text}} + +{{custom-footer-text}} +{{#i18n}}email.kind-regards{{/i18n}} + +{{organization.name}} <{{organization.email}}> \ No newline at end of file diff --git a/src/main/resources/alfio/templates/credit-note-issued-email-txt.ms b/src/main/resources/alfio/templates/credit-note-issued-email-txt.ms index 925335ed89..4cbbed4160 100644 --- a/src/main/resources/alfio/templates/credit-note-issued-email-txt.ms +++ b/src/main/resources/alfio/templates/credit-note-issued-email-txt.ms @@ -1,6 +1,6 @@ {{#i18n}}email.hello [{{ticketReservation.fullName}}]{{/i18n}} -{{#i18n}}reservation.credit-note-issued [{{event.displayName}}]{{/i18n}} +{{#i18n}}reservation.credit-note-issued [{{purchaseContext.displayName}}]{{/i18n}} {{#i18n}}email.kind-regards{{/i18n}} diff --git a/src/main/resources/alfio/templates/credit-note.ms b/src/main/resources/alfio/templates/credit-note.ms index ff06005bcd..af141e31bd 100644 --- a/src/main/resources/alfio/templates/credit-note.ms +++ b/src/main/resources/alfio/templates/credit-note.ms @@ -105,7 +105,7 @@ {{#eventImage}}{{/eventImage}} - {{^eventImage}}{{/eventImage}} + {{^eventImage}}{{/eventImage}}

{{event.displayName}}

{{purchaseContext.displayName}}

{{#i18n}}invoice.credit-note{{/i18n}}

@@ -141,19 +141,19 @@ -

{{event.displayName}}, +

{{purchaseContext.displayName}}{{#isEvent}}, - {{#event.sameDay}}{{#i18n}}event-days.same-day [{{#format-date}}{{event.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{event.begin}} HH:mm{{/format-date}}] [{{#format-date}}{{event.end}} HH:mm (z){{/format-date}}] {{/i18n}}{{/event.sameDay}} - {{^event.sameDay}}{{#i18n}}event-days.not-same-day [{{#format-date}}{{event.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{event.begin}} HH:mm{{/format-date}}]{{/i18n}} - {{#i18n}}event-days.not-same-day [{{#format-date}}{{event.end}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{event.end}} HH:mm (z){{/format-date}}]{{/i18n}} {{/event.sameDay}} - + {{#purchaseContext.sameDay}}{{#i18n}}event-days.same-day [{{#format-date}}{{purchaseContext.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{purchaseContext.begin}} HH:mm{{/format-date}}] [{{#format-date}}{{purchaseContext.end}} HH:mm (z){{/format-date}}] {{/i18n}}{{/purchaseContext.sameDay}} + {{^purchaseContext.sameDay}}{{#i18n}}event-days.not-same-day [{{#format-date}}{{purchaseContext.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{purchaseContext.begin}} HH:mm{{/format-date}}]{{/i18n}} - {{#i18n}}event-days.not-same-day [{{#format-date}}{{purchaseContext.end}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{purchaseContext.end}} HH:mm (z){{/format-date}}]{{/i18n}} {{/purchaseContext.sameDay}} + {{/isEvent}}

- - + + @@ -175,7 +175,7 @@ {{/orderSummary.displayVat}} {{/orderSummary.free}} - +
{{#i18n}}reservation-page.amount{{/i18n}} {{#i18n}}reservation-page.category{{/i18n}}{{#i18n}}reservation-page.price{{/i18n}} ({{event.currency}}){{#i18n}}reservation-page.subtotal{{/i18n}} ({{event.currency}}){{#i18n}}reservation-page.price{{/i18n}} ({{purchaseContext.currency}}){{#i18n}}reservation-page.subtotal{{/i18n}} ({{purchaseContext.currency}})
{{#i18n}}invoice.vat-voided [{{vatTranslation}}]{{/i18n}}
{{#i18n}}reservation-page.total{{/i18n}} {{event.currency}}- {{orderSummary.totalPrice}}
{{#i18n}}reservation-page.total{{/i18n}} {{purchaseContext.currency}}- {{orderSummary.totalPrice}}
diff --git a/src/main/resources/alfio/templates/invoice.ms b/src/main/resources/alfio/templates/invoice.ms index 074b1247b6..ff1e4d6c08 100644 --- a/src/main/resources/alfio/templates/invoice.ms +++ b/src/main/resources/alfio/templates/invoice.ms @@ -105,7 +105,7 @@ {{#eventImage}}{{/eventImage}} - {{^eventImage}}{{/eventImage}} + {{^eventImage}}{{/eventImage}}

{{event.displayName}}

{{purchaseContext.displayName}}

{{#i18n}}invoice.vat-invoice [{{vatTranslation}}]{{/i18n}}

@@ -141,19 +141,19 @@ -

{{event.displayName}}, +

{{purchaseContext.displayName}}{{#isEvent}}, - {{#event.sameDay}}{{#i18n}}event-days.same-day [{{#format-date}}{{event.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{event.begin}} HH:mm{{/format-date}}] [{{#format-date}}{{event.end}} HH:mm (z){{/format-date}}] {{/i18n}}{{/event.sameDay}} - {{^event.sameDay}}{{#i18n}}event-days.not-same-day [{{#format-date}}{{event.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{event.begin}} HH:mm{{/format-date}}]{{/i18n}} - {{#i18n}}event-days.not-same-day [{{#format-date}}{{event.end}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{event.end}} HH:mm (z){{/format-date}}]{{/i18n}} {{/event.sameDay}} - + {{#purchaseContext.sameDay}}{{#i18n}}event-days.same-day [{{#format-date}}{{purchaseContext.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{purchaseContext.begin}} HH:mm{{/format-date}}] [{{#format-date}}{{purchaseContext.end}} HH:mm (z){{/format-date}}] {{/i18n}}{{/purchaseContext.sameDay}} + {{^purchaseContext.sameDay}}{{#i18n}}event-days.not-same-day [{{#format-date}}{{purchaseContext.begin}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{purchaseContext.begin}} HH:mm{{/format-date}}]{{/i18n}} - {{#i18n}}event-days.not-same-day [{{#format-date}}{{purchaseContext.end}} EEEE dd MMMM yyyy locale:{{#i18n}}locale{{/i18n}}{{/format-date}}] [{{#format-date}}{{purchaseContext.end}} HH:mm (z){{/format-date}}]{{/i18n}} {{/purchaseContext.sameDay}} + {{/isEvent}}

- - + + @@ -175,7 +175,7 @@ {{/orderSummary.displayVat}} {{/orderSummary.free}} - +
{{#i18n}}reservation-page.amount{{/i18n}} {{#i18n}}reservation-page.category{{/i18n}}{{#i18n}}reservation-page.price{{/i18n}} ({{event.currency}}){{#i18n}}reservation-page.subtotal{{/i18n}} ({{event.currency}}){{#i18n}}reservation-page.price{{/i18n}} ({{purchaseContext.currency}}){{#i18n}}reservation-page.subtotal{{/i18n}} ({{purchaseContext.currency}})
{{#i18n}}invoice.vat-voided [{{vatTranslation}}]{{/i18n}}
{{#i18n}}reservation-page.total{{/i18n}}{{event.currency}} {{orderSummary.totalPrice}}
{{#i18n}}reservation-page.total{{/i18n}}{{purchaseContext.currency}} {{orderSummary.totalPrice}}
@@ -184,7 +184,7 @@

{{ticketReservation.id}}

{{#i18n}}invoice.buyer{{/i18n}} {{ticketReservation.fullName}} <{{ticketReservation.email}}>

{{#hasRefund}} -

{{#i18n}}invoice.refund [{{event.currency}} {{orderSummary.refundedAmount}}]{{/i18n}}

+

{{#i18n}}invoice.refund [{{purchaseContext.currency}} {{orderSummary.refundedAmount}}]{{/i18n}}

{{/hasRefund}} diff --git a/src/main/resources/alfio/web-templates/admin-index.ms b/src/main/resources/alfio/web-templates/admin-index.ms index 954eb813e0..71e868a9a2 100644 --- a/src/main/resources/alfio/web-templates/admin-index.ms +++ b/src/main/resources/alfio/web-templates/admin-index.ms @@ -93,6 +93,7 @@ + @@ -130,6 +131,7 @@