/** * 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 <http://www.gnu.org/licenses/>. */ package alfio.controller; import alfio.controller.api.support.TicketHelper; import alfio.controller.form.PaymentForm; import alfio.controller.form.UpdateTicketOwnerForm; import alfio.controller.support.SessionUtil; import alfio.controller.support.TicketDecorator; import alfio.manager.*; import alfio.manager.support.PaymentResult; import alfio.manager.system.ConfigurationManager; import alfio.model.*; import alfio.model.TicketReservation.TicketReservationStatus; import alfio.model.result.ValidationResult; import alfio.model.system.Configuration; import alfio.model.transaction.PaymentProxy; import alfio.model.user.Organization; import alfio.repository.EventRepository; import alfio.repository.TicketFieldRepository; import alfio.repository.TicketRepository; import alfio.repository.user.OrganizationRepository; import alfio.util.ErrorsCode; import alfio.util.TemplateManager; import alfio.util.TemplateResource; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Triple; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.support.RequestContextUtils; import javax.servlet.http.HttpServletRequest; import java.time.ZonedDateTime; import java.util.*; import java.util.stream.Collectors; import static alfio.model.system.ConfigurationKeys.*; import static java.util.stream.Collectors.toList; @Controller @Log4j2 public class ReservationController { private final EventRepository eventRepository; private final EventManager eventManager; private final TicketReservationManager ticketReservationManager; private final OrganizationRepository organizationRepository; private final TemplateManager templateManager; private final MessageSource messageSource; private final ConfigurationManager configurationManager; private final NotificationManager notificationManager; private final TicketHelper ticketHelper; private final TicketFieldRepository ticketFieldRepository; private final PaymentManager paymentManager; private final TicketRepository ticketRepository; @Autowired public ReservationController(EventRepository eventRepository, EventManager eventManager, TicketReservationManager ticketReservationManager, OrganizationRepository organizationRepository, TemplateManager templateManager, MessageSource messageSource, ConfigurationManager configurationManager, NotificationManager notificationManager, TicketHelper ticketHelper, TicketFieldRepository ticketFieldRepository, PaymentManager paymentManager, TicketRepository ticketRepository) { this.eventRepository = eventRepository; this.eventManager = eventManager; this.ticketReservationManager = ticketReservationManager; this.organizationRepository = organizationRepository; this.templateManager = templateManager; this.messageSource = messageSource; this.configurationManager = configurationManager; this.notificationManager = notificationManager; this.ticketHelper = ticketHelper; this.ticketFieldRepository = ticketFieldRepository; this.paymentManager = paymentManager; this.ticketRepository = ticketRepository; } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/book", method = RequestMethod.GET) public String showPaymentPage(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, //paypal related parameters @RequestParam(value = "paymentId", required = false) String paypalPaymentId, @RequestParam(value = "PayerID", required = false) String paypalPayerID, @RequestParam(value = "paypal-success", required = false) Boolean isPaypalSuccess, @RequestParam(value = "paypal-error", required = false) Boolean isPaypalError, @RequestParam(value = "fullName", required = false) String fullName, @RequestParam(value = "firstName", required = false) String firstName, @RequestParam(value = "lastName", required = false) String lastName, @RequestParam(value = "email", required = false) String email, @RequestParam(value = "billingAddress", required = false) String billingAddress, @RequestParam(value = "hmac", required = false) String hmac, @RequestParam(value = "postponeAssignment", required = false) Boolean postponeAssignment, Model model, Locale locale) { return eventRepository.findOptionalByShortName(eventName) .map(event -> ticketReservationManager.findById(reservationId) .map(reservation -> { if (reservation.getStatus() != TicketReservationStatus.PENDING) { return redirectReservation(Optional.of(reservation), eventName, reservationId); } if (Boolean.TRUE.equals(isPaypalSuccess) && paypalPayerID != null && paypalPaymentId != null) { model.addAttribute("paypalPaymentId", paypalPaymentId) .addAttribute("paypalPayerID", paypalPayerID) .addAttribute("paypalCheckoutConfirmation", true) .addAttribute("fullName", fullName) .addAttribute("firstName", firstName) .addAttribute("lastName", lastName) .addAttribute("email", email) .addAttribute("billingAddress", billingAddress) .addAttribute("hmac", hmac) .addAttribute("postponeAssignment", Boolean.TRUE.equals(postponeAssignment)) .addAttribute("showPostpone", Boolean.TRUE.equals(postponeAssignment)); } else { model.addAttribute("paypalCheckoutConfirmation", false) .addAttribute("postponeAssignment", false) .addAttribute("showPostpone", true); } try { model.addAttribute("delayForOfflinePayment", Math.max(1, TicketReservationManager.getOfflinePaymentWaitingPeriod(event, configurationManager))); } catch (TicketReservationManager.OfflinePaymentException e) { if(event.getAllowedPaymentProxies().contains(PaymentProxy.OFFLINE)) { log.error("Already started event {} has been found with OFFLINE payment enabled" , event.getDisplayName() , e); } model.addAttribute("delayForOfflinePayment", 0); } OrderSummary orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, event, locale); List<PaymentProxy> activePaymentMethods = paymentManager.getPaymentMethods(event.getOrganizationId()) .stream() .filter(p -> TicketReservationManager.isValidPaymentMethod(p, event, configurationManager)) .map(PaymentManager.PaymentMethod::getPaymentProxy) .collect(toList()); model.addAttribute("multiplePaymentMethods" , activePaymentMethods.size() > 1 ); model.addAttribute("orderSummary", orderSummary); model.addAttribute("reservationId", reservationId); model.addAttribute("reservation", reservation); model.addAttribute("pageTitle", "reservation-page.header.title"); model.addAttribute("event", event); model.addAttribute("activePaymentMethods", activePaymentMethods); model.addAttribute("expressCheckoutEnabled", isExpressCheckoutEnabled(event, orderSummary)); model.addAttribute("useFirstAndLastName", event.mustUseFirstAndLastName()); model.addAttribute("countries", ticketHelper.getLocalizedCountries(locale)); boolean includeStripe = !orderSummary.getFree() && activePaymentMethods.contains(PaymentProxy.STRIPE); model.addAttribute("includeStripe", includeStripe); if (includeStripe) { model.addAttribute("stripe_p_key", paymentManager.getStripePublicKey(event)); } Map<String, Object> modelMap = model.asMap(); modelMap.putIfAbsent("paymentForm", PaymentForm.fromExistingReservation(reservation)); modelMap.putIfAbsent("hasErrors", false); model.addAttribute( "ticketsByCategory", ticketReservationManager.findTicketsInReservation(reservationId).stream().collect(Collectors.groupingBy(Ticket::getCategoryId)).entrySet().stream() .map((e) -> { TicketCategory category = eventManager.getTicketCategoryById(e.getKey(), event.getId()); List<TicketDecorator> decorators = TicketDecorator.decorate(e.getValue(), configurationManager.getBooleanConfigValue(Configuration.from(event.getOrganizationId(), event.getId(), category.getId(), ALLOW_FREE_TICKETS_CANCELLATION), false), eventManager.checkTicketCancellationPrerequisites(), ticket -> ticketHelper.findTicketFieldConfigurationAndValue(event.getId(), ticket, locale), true, (t) -> "tickets['"+t.getUuid()+"']."); return Pair.of(category, decorators); }) .collect(toList())); return "/event/reservation-page"; }).orElseGet(() -> redirectReservation(Optional.empty(), eventName, reservationId))) .orElse("redirect:/"); } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/success", method = RequestMethod.GET) public String showConfirmationPage(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @RequestParam(value = "confirmation-email-sent", required = false, defaultValue = "false") boolean confirmationEmailSent, @RequestParam(value = "ticket-email-sent", required = false, defaultValue = "false") boolean ticketEmailSent, Model model, Locale locale, HttpServletRequest request) { return eventRepository.findOptionalByShortName(eventName).map(ev -> { Optional<TicketReservation> tr = ticketReservationManager.findById(reservationId); return tr.filter(r -> r.getStatus() == TicketReservationStatus.COMPLETE) .map(reservation -> { SessionUtil.removeSpecialPriceData(request); model.addAttribute("reservationId", reservationId); model.addAttribute("reservation", reservation); model.addAttribute("confirmationEmailSent", confirmationEmailSent); model.addAttribute("ticketEmailSent", ticketEmailSent); List<Ticket> tickets = ticketReservationManager.findTicketsInReservation(reservationId); List<Triple<AdditionalService, List<AdditionalServiceText>, AdditionalServiceItem>> additionalServices = ticketReservationManager.findAdditionalServicesInReservation(reservationId) .stream() .map(t -> Triple.of(t.getLeft(), t.getMiddle().stream().filter(d -> d.getLocale().equals(locale.getLanguage())).collect(toList()), t.getRight())) .collect(Collectors.toList()); model.addAttribute( "ticketsByCategory", tickets.stream().collect(Collectors.groupingBy(Ticket::getCategoryId)).entrySet().stream() .map((e) -> { TicketCategory category = eventManager.getTicketCategoryById(e.getKey(), ev.getId()); List<TicketDecorator> decorators = TicketDecorator.decorate(e.getValue(), configurationManager.getBooleanConfigValue(Configuration.from(ev.getOrganizationId(), ev.getId(), category.getId(), ALLOW_FREE_TICKETS_CANCELLATION), false), eventManager.checkTicketCancellationPrerequisites(), ticket -> ticketHelper.findTicketFieldConfigurationAndValue(ev.getId(), ticket, locale), tickets.size() == 1, TicketDecorator.EMPTY_PREFIX_GENERATOR); return Pair.of(category, decorators); }) .collect(toList())); boolean ticketsAllAssigned = tickets.stream().allMatch(Ticket::getAssigned); model.addAttribute("ticketsAreAllAssigned", ticketsAllAssigned); model.addAttribute("collapseEnabled", tickets.size() > 1 && !ticketsAllAssigned); model.addAttribute("additionalServicesOnly", tickets.isEmpty() && !additionalServices.isEmpty()); model.addAttribute("additionalServices", additionalServices); model.addAttribute("countries", ticketHelper.getLocalizedCountries(locale)); model.addAttribute("pageTitle", "reservation-page-complete.header.title"); model.addAttribute("event", ev); model.addAttribute("useFirstAndLastName", ev.mustUseFirstAndLastName()); model.asMap().putIfAbsent("validationResult", ValidationResult.success()); return "/event/reservation-page-complete"; }).orElseGet(() -> redirectReservation(tr, eventName, reservationId)); }).orElse("redirect:/"); } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/failure", method = RequestMethod.GET) public String showFailurePage(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @RequestParam(value = "confirmation-email-sent", required = false, defaultValue = "false") boolean confirmationEmailSent, @RequestParam(value = "ticket-email-sent", required = false, defaultValue = "false") boolean ticketEmailSent, Model model) { Optional<Event> event = eventRepository.findOptionalByShortName(eventName); if (!event.isPresent()) { return "redirect:/"; } Optional<TicketReservation> reservation = ticketReservationManager.findById(reservationId); Optional<TicketReservationStatus> status = reservation.map(TicketReservation::getStatus); if(!status.isPresent()) { return redirectReservation(reservation, eventName, reservationId); } TicketReservationStatus ticketReservationStatus = status.get(); if(ticketReservationStatus == TicketReservationStatus.IN_PAYMENT || ticketReservationStatus == TicketReservationStatus.STUCK) { model.addAttribute("reservation", reservation.get()); model.addAttribute("organizer", organizationRepository.getById(event.get().getOrganizationId())); model.addAttribute("pageTitle", "reservation-page-error-status.header.title"); model.addAttribute("event", event.get()); return "/event/reservation-page-error-status"; } return redirectReservation(reservation, eventName, reservationId); } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}", method = RequestMethod.GET) public String showReservationPage(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Model model) { Optional<Event> event = eventRepository.findOptionalByShortName(eventName); if (!event.isPresent()) { return "redirect:/"; } return redirectReservation(ticketReservationManager.findById(reservationId), eventName, reservationId); } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/notfound", method = RequestMethod.GET) public String showNotFoundPage(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Model model) { Optional<Event> event = eventRepository.findOptionalByShortName(eventName); if (!event.isPresent()) { return "redirect:/"; } Optional<TicketReservation> reservation = ticketReservationManager.findById(reservationId); if(!reservation.isPresent()) { model.addAttribute("reservationId", reservationId); model.addAttribute("pageTitle", "reservation-page-not-found.header.title"); model.addAttribute("event", event.get()); return "/event/reservation-page-not-found"; } return redirectReservation(reservation, eventName, reservationId); } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/waitingPayment", method = RequestMethod.GET) public String showWaitingPaymentPage(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Model model, Locale locale) { Optional<Event> event = eventRepository.findOptionalByShortName(eventName); if (!event.isPresent()) { return "redirect:/"; } Optional<TicketReservation> reservation = ticketReservationManager.findById(reservationId); TicketReservationStatus status = reservation.map(TicketReservation::getStatus).orElse(TicketReservationStatus.PENDING); if(reservation.isPresent() && status == TicketReservationStatus.OFFLINE_PAYMENT) { Event ev = event.get(); TicketReservation ticketReservation = reservation.get(); OrderSummary orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, ev, locale); model.addAttribute("totalPrice", orderSummary.getTotalPrice()); model.addAttribute("emailAddress", organizationRepository.getById(ev.getOrganizationId()).getEmail()); model.addAttribute("reservation", ticketReservation); model.addAttribute("paymentReason", ev.getShortName() + " " + ticketReservationManager.getShortReservationID(ev, reservationId)); model.addAttribute("pageTitle", "reservation-page-waiting.header.title"); model.addAttribute("bankAccount", configurationManager.getStringConfigValue(Configuration.from(ev.getOrganizationId(), ev.getId(), BANK_ACCOUNT_NR)).orElse("")); Optional<String> maybeAccountOwner = configurationManager.getStringConfigValue(Configuration.from(ev.getOrganizationId(), ev.getId(), BANK_ACCOUNT_OWNER)); model.addAttribute("hasBankAccountOwnerSet", maybeAccountOwner.isPresent()); model.addAttribute("bankAccountOwner", Arrays.asList(maybeAccountOwner.orElse("").split("\n"))); model.addAttribute("expires", ZonedDateTime.ofInstant(ticketReservation.getValidity().toInstant(), ev.getZoneId())); model.addAttribute("event", ev); return "/event/reservation-waiting-for-payment"; } return redirectReservation(reservation, eventName, reservationId); } private String redirectReservation(Optional<TicketReservation> ticketReservation, String eventName, String reservationId) { String baseUrl = "redirect:/event/" + eventName + "/reservation/" + reservationId; if(!ticketReservation.isPresent()) { return baseUrl + "/notfound"; } TicketReservation reservation = ticketReservation.get(); switch(reservation.getStatus()) { case PENDING: return baseUrl + "/book"; case COMPLETE: return baseUrl + "/success"; case OFFLINE_PAYMENT: return baseUrl + "/waitingPayment"; case IN_PAYMENT: case STUCK: return baseUrl + "/failure"; } return "redirect:/"; } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}", method = RequestMethod.POST) public String handleReservation(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, PaymentForm paymentForm, BindingResult bindingResult, Model model, HttpServletRequest request, Locale locale, RedirectAttributes redirectAttributes) { Optional<Event> eventOptional = eventRepository.findOptionalByShortName(eventName); if (!eventOptional.isPresent()) { return "redirect:/"; } Event event = eventOptional.get(); Optional<TicketReservation> ticketReservation = ticketReservationManager.findById(reservationId); if (!ticketReservation.isPresent()) { return redirectReservation(ticketReservation, eventName, reservationId); } if (paymentForm.shouldCancelReservation()) { ticketReservationManager.cancelPendingReservation(reservationId, false); SessionUtil.removeSpecialPriceData(request); return "redirect:/event/" + eventName + "/"; } if (!ticketReservation.get().getValidity().after(new Date())) { bindingResult.reject(ErrorsCode.STEP_2_ORDER_EXPIRED); } final TotalPrice reservationCost = ticketReservationManager.totalReservationCostWithVAT(reservationId); if(!paymentForm.isPostponeAssignment() && !ticketRepository.checkTicketUUIDs(reservationId, paymentForm.getTickets().keySet())) { bindingResult.reject(ErrorsCode.STEP_2_MISSING_ATTENDEE_DATA); } paymentForm.validate(bindingResult, reservationCost, event, ticketFieldRepository.findAdditionalFieldsForEvent(event.getId())); if (bindingResult.hasErrors()) { SessionUtil.addToFlash(bindingResult, redirectAttributes); return redirectReservation(ticketReservation, eventName, reservationId); } CustomerName customerName = new CustomerName(paymentForm.getFullName(), paymentForm.getFirstName(), paymentForm.getLastName(), event); //handle paypal redirect! if(paymentForm.getPaymentMethod() == PaymentProxy.PAYPAL && !paymentForm.hasPaypalTokens()) { OrderSummary orderSummary = ticketReservationManager.orderSummaryForReservationId(reservationId, event, locale); try { String checkoutUrl = paymentManager.createPaypalCheckoutRequest(event, reservationId, orderSummary, customerName, paymentForm.getEmail(), paymentForm.getBillingAddress(), locale, paymentForm.isPostponeAssignment()); assignTickets(eventName, reservationId, paymentForm, bindingResult, request, true); return "redirect:" + checkoutUrl; } catch (Exception e) { bindingResult.reject(ErrorsCode.STEP_2_PAYMENT_REQUEST_CREATION); SessionUtil.addToFlash(bindingResult, redirectAttributes); return redirectReservation(ticketReservation, eventName, reservationId); } } // final PaymentResult status = ticketReservationManager.confirm(paymentForm.getToken(), paymentForm.getPaypalPayerID(), event, reservationId, paymentForm.getEmail(), customerName, locale, paymentForm.getBillingAddress(), reservationCost, SessionUtil.retrieveSpecialPriceSessionId(request), Optional.ofNullable(paymentForm.getPaymentMethod())); if(!status.isSuccessful()) { String errorMessageCode = status.getErrorCode().get(); MessageSourceResolvable message = new DefaultMessageSourceResolvable(new String[]{errorMessageCode, StripeManager.STRIPE_UNEXPECTED}); bindingResult.reject(ErrorsCode.STEP_2_PAYMENT_PROCESSING_ERROR, new Object[]{messageSource.getMessage(message, locale)}, null); SessionUtil.addToFlash(bindingResult, redirectAttributes); return redirectReservation(ticketReservation, eventName, reservationId); } // TicketReservation reservation = ticketReservationManager.findById(reservationId).orElseThrow(IllegalStateException::new); sendReservationCompleteEmail(request, event,reservation); sendReservationCompleteEmailToOrganizer(request, event, reservation); // if(paymentForm.getPaymentMethod() != PaymentProxy.PAYPAL) { assignTickets(eventName, reservationId, paymentForm, bindingResult, request, paymentForm.getPaymentMethod() == PaymentProxy.OFFLINE); } return "redirect:/event/" + eventName + "/reservation/" + reservationId + "/success"; } private void assignTickets(String eventName, String reservationId, PaymentForm paymentForm, BindingResult bindingResult, HttpServletRequest request, boolean preAssign) { if(!paymentForm.isPostponeAssignment()) { paymentForm.getTickets().entrySet().forEach(t -> { String ticketId = t.getKey(); UpdateTicketOwnerForm owner = t.getValue(); if(preAssign) { ticketHelper.preAssignTicket(eventName, reservationId, ticketId, owner, Optional.of(bindingResult), request, (tr) -> {}, Optional.empty()); } else { ticketHelper.assignTicket(eventName, ticketId, owner, Optional.of(bindingResult), request, (tr) -> {}, Optional.empty()); } }); } } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/re-send-email", method = RequestMethod.POST) public String reSendReservationConfirmationEmail(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, HttpServletRequest request) { Optional<Event> event = eventRepository.findOptionalByShortName(eventName); if (!event.isPresent()) { return "redirect:/"; } Optional<TicketReservation> ticketReservation = ticketReservationManager.findById(reservationId); if (!ticketReservation.isPresent()) { return "redirect:/event/" + eventName + "/"; } sendReservationCompleteEmail(request, event.get(), ticketReservation.orElseThrow(IllegalStateException::new)); return "redirect:/event/" + eventName + "/reservation/" + reservationId + "/success?confirmation-email-sent=true"; } @RequestMapping(value = "/event/{eventName}/reservation/{reservationId}/ticket/{ticketIdentifier}/assign", method = RequestMethod.POST) public String assignTicketToPerson(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, @PathVariable("ticketIdentifier") String ticketIdentifier, UpdateTicketOwnerForm updateTicketOwner, BindingResult bindingResult, HttpServletRequest request, Model model) throws Exception { Optional<Triple<ValidationResult, Event, Ticket>> result = ticketHelper.assignTicket(eventName, reservationId, ticketIdentifier, updateTicketOwner, Optional.of(bindingResult), request, model); return result.map(t -> "redirect:/event/"+t.getMiddle().getShortName()+"/reservation/"+t.getRight().getTicketsReservationId()+"/success").orElse("redirect:/"); } private void sendReservationCompleteEmail(HttpServletRequest request, Event event, TicketReservation reservation) { Locale locale = RequestContextUtils.getLocale(request); ticketReservationManager.sendConfirmationEmail(event, reservation, locale); } private void sendReservationCompleteEmailToOrganizer(HttpServletRequest request, Event event, TicketReservation reservation) { Organization organization = organizationRepository.getById(event.getOrganizationId()); notificationManager.sendSimpleEmail(event, organization.getEmail(), "Reservation complete " + reservation.getId(), () -> { return templateManager.renderTemplate(event, TemplateResource.CONFIRMATION_EMAIL_FOR_ORGANIZER, ticketReservationManager.prepareModelForReservationEmail(event, reservation), RequestContextUtils.getLocale(request)); }); } private boolean isExpressCheckoutEnabled(Event event, OrderSummary orderSummary) { return orderSummary.getTicketAmount() == 1 && ticketFieldRepository.countRequiredAdditionalFieldsForEvent(event.getId()) == 0; } }