Skip to content

Commit

Permalink
Payments list (#1240)
Browse files Browse the repository at this point in the history
* add notes + customize transaction timestamp

* WIP component creation

* list / edit transaction details

* download XLS export

* add ownership checks
  • Loading branch information
cbellone authored May 26, 2023
1 parent 26f4a50 commit 51c2eda
Show file tree
Hide file tree
Showing 27 changed files with 707 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* 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.api.admin;

import alfio.controller.api.support.PageAndContent;
import alfio.manager.PaymentManager;
import alfio.manager.PurchaseContextManager;
import alfio.manager.PurchaseContextSearchManager;
import alfio.manager.system.ConfigurationManager;
import alfio.model.PurchaseContext;
import alfio.model.ReservationPaymentDetail;
import alfio.model.modification.TransactionMetadataModification;
import alfio.model.system.ConfigurationKeys;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.Principal;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

import static alfio.util.ExportUtils.exportExcel;
import static java.util.Objects.requireNonNullElse;
import static org.apache.commons.lang3.StringUtils.trimToNull;

@RestController
@RequestMapping("/admin/api/payments")
public class AdminPaymentsApiController {

private static final String[] EXPORT_COLUMNS = new String[] {
"ID",
"Name",
"Email",
"Type",
"Amount",
"Currency",
"Payment Date/Time",
"Notes"
};
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

private final PurchaseContextSearchManager purchaseContextSearchManager;
private final PurchaseContextManager purchaseContextManager;
private final PaymentManager paymentManager;
private final ConfigurationManager configurationManager;

public AdminPaymentsApiController(PurchaseContextSearchManager purchaseContextSearchManager,
PurchaseContextManager purchaseContextManager,
PaymentManager paymentManager,
ConfigurationManager configurationManager) {
this.purchaseContextSearchManager = purchaseContextSearchManager;
this.purchaseContextManager = purchaseContextManager;
this.paymentManager = paymentManager;
this.configurationManager = configurationManager;
}

@GetMapping("/{purchaseContextType}/{publicIdentifier}/list")
PageAndContent<List<ReservationPaymentDetail>> getPaymentsForPurchaseContext(@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) {
return purchaseContextManager.findBy(purchaseContextType, publicIdentifier)
.filter(purchaseContext -> purchaseContextManager.validateAccess(purchaseContext, principal))
.map(purchaseContext -> {
var res = purchaseContextSearchManager.findAllPaymentsFor(purchaseContext, page, search);
return new PageAndContent<>(res.getLeft(), res.getRight());
}).orElseGet(() -> new PageAndContent<>(List.of(), 0));
}

@PutMapping("/{purchaseContextType}/{publicIdentifier}/reservation/{reservationId}")
ResponseEntity<String> updateTransactionData(@PathVariable("purchaseContextType") PurchaseContext.PurchaseContextType purchaseContextType,
@PathVariable("publicIdentifier") String publicIdentifier,
@PathVariable("reservationId") String reservationId,
@RequestBody TransactionMetadataModification transactionMetadataModification,
Principal principal) {
try {
return ResponseEntity.of(purchaseContextManager.findBy(purchaseContextType, publicIdentifier)
.filter(purchaseContext -> purchaseContextManager.validateAccess(purchaseContext, principal))
.map(purchaseContext -> {
var timestampModification = transactionMetadataModification.getTimestamp();
var timestamp = timestampModification != null ? timestampModification.toZonedDateTime(purchaseContext.getZoneId()) : null;
paymentManager.updateTransactionDetails(reservationId, transactionMetadataModification.getNotes(), timestamp, principal);
return "OK";
}));
} catch (IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
}

@GetMapping("/{purchaseContextType}/{publicIdentifier}/download")
void exportPayments(@PathVariable("purchaseContextType") PurchaseContext.PurchaseContextType purchaseContextType,
@PathVariable("publicIdentifier") String publicIdentifier,
@RequestParam(value = "search", required = false) String search,
Principal principal,
HttpServletResponse response) throws IOException {
var purchaseContextOptional = purchaseContextManager.findBy(purchaseContextType, publicIdentifier);
if (purchaseContextOptional.isPresent() && purchaseContextManager.validateAccess(purchaseContextOptional.get(), principal)) {
var purchaseContext = purchaseContextOptional.get();
boolean useInvoiceNumber = configurationManager.getFor(ConfigurationKeys.USE_INVOICE_NUMBER_AS_ID, purchaseContext.getConfigurationLevel())
.getValueAsBooleanOrDefault();
var data = purchaseContextSearchManager.findAllPaymentsForExport(purchaseContext, search)
.stream()
.map(d -> new String[] {
useInvoiceNumber ? requireNonNullElse(trimToNull(d.getInvoiceNumber()), "N/A") : d.getId(),
d.getFirstName() + " " + d.getLastName(),
d.getEmail(),
d.getPaymentMethod(),
d.getPaidAmount(),
d.getCurrencyCode(),
formatTimestamp(purchaseContext, d.getTransactionTimestamp()),
d.getTransactionNotes()
});
exportExcel(purchaseContext.getDisplayName()+" payments.xlsx", "Payments", EXPORT_COLUMNS, data, response);
} else {
response.setContentType("text/plain");
response.setStatus(HttpStatus.PRECONDITION_REQUIRED.value());
response.getWriter().write("No payments found");
}
}

private static String formatTimestamp(PurchaseContext purchaseContext, String timestamp) {
return ZonedDateTime.parse(timestamp)
.withZoneSameInstant(purchaseContext.getZoneId())
.format(DATE_TIME_FORMATTER);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -571,9 +571,12 @@ public Integer getPendingPaymentsCount(@PathVariable("eventName") String eventNa
}

@PostMapping("/events/{eventName}/pending-payments/{reservationId}/confirm")
public String confirmPayment(@PathVariable("eventName") String eventName, @PathVariable("reservationId") String reservationId, Principal principal) {
public String confirmPayment(@PathVariable("eventName") String eventName,
@PathVariable("reservationId") String reservationId,
@RequestBody TransactionMetadataModification transactionMetadataModification,
Principal principal) {
var event = loadEvent(eventName, principal);
ticketReservationManager.confirmOfflinePayment(event, reservationId, principal.getName());
ticketReservationManager.confirmOfflinePayment(event, reservationId, transactionMetadataModification, principal.getName());
ticketReservationManager.findById(reservationId)
.filter(TicketReservation::isDirectAssignmentRequested)
.ifPresent(reservation -> {
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/alfio/manager/CheckInManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ private void acquire(String uuid) {
Ticket ticket = ticketRepository.findByUUID(uuid);
Validate.isTrue(ticket.getStatus() == TicketStatus.TO_BE_PAID);
ticketRepository.updateTicketStatusWithUUID(uuid, TicketStatus.ACQUIRED.toString());
ticketReservationManager.registerAlfioTransaction(eventRepository.findById(ticket.getEventId()), ticket.getTicketsReservationId(), PaymentProxy.ON_SITE);
ticketReservationManager.registerAlfioTransactionForOnsitePayment(eventRepository.findById(ticket.getEventId()), ticket.getTicketsReservationId());
}

public AttendeeSearchResults searchAttendees(Event event, String query, int page) {
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/alfio/manager/PaymentManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,19 @@
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.stereotype.Component;

import java.security.Principal;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.Objects.requireNonNullElse;

@Component
@Log4j2
@AllArgsConstructor
Expand Down Expand Up @@ -187,6 +192,19 @@ public TransactionAndPaymentInfo getInfo(TicketReservation reservation, Purchase
return maybeTransaction.orElseGet(() -> new TransactionAndPaymentInfo(reservation.getPaymentMethod(),null, new PaymentInformation(reservation.getPaidAmount(), null, null, null)));
}

public void updateTransactionDetails(String reservationId,
String notes,
ZonedDateTime timestamp,
Principal principal) {
// TODO check if user can modify transaction once we have a centralized service.
var existingTransaction = transactionRepository.loadByReservationId(reservationId);
Validate.isTrue(existingTransaction.isTimestampEditable() || timestamp == null, "Cannot modify timestamp");
var existingMetadata = new HashMap<>(existingTransaction.getMetadata());
existingMetadata.put(Transaction.NOTES_KEY, notes);
int result = transactionRepository.updateDetailsById(existingTransaction.getId(), existingMetadata, requireNonNullElse(timestamp, existingTransaction.getTimestamp()));
Validate.isTrue(result == 1, "Expected 1, got " + result);
}

private boolean feesUpdated(Transaction transaction, PaymentInformation paymentInformation) {
return transaction.getPlatformFee() != safeParseLong(paymentInformation.getPlatformFee())
|| transaction.getGatewayFee() != safeParseLong(paymentInformation.getFee());
Expand Down
14 changes: 13 additions & 1 deletion src/main/java/alfio/manager/PurchaseContextManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@
import alfio.repository.EventRepository;
import alfio.repository.SubscriptionRepository;
import alfio.repository.TicketReservationRepository;
import alfio.repository.user.OrganizationRepository;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.security.Principal;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -39,13 +42,16 @@ public class PurchaseContextManager {
private final EventRepository eventRepository;
private final SubscriptionRepository subscriptionRepository;
private final TicketReservationRepository ticketReservationRepository;
private final OrganizationRepository organizationRepository;

public PurchaseContextManager(EventRepository eventRepository,
SubscriptionRepository subscriptionRepository,
TicketReservationRepository ticketReservationRepository) {
TicketReservationRepository ticketReservationRepository,
OrganizationRepository organizationRepository) {
this.eventRepository = eventRepository;
this.subscriptionRepository = subscriptionRepository;
this.ticketReservationRepository = ticketReservationRepository;
this.organizationRepository = organizationRepository;
}

public Optional<? extends PurchaseContext> findBy(PurchaseContext.PurchaseContextType purchaseContextType, String publicIdentifier) {
Expand All @@ -56,6 +62,12 @@ public Optional<? extends PurchaseContext> findBy(PurchaseContext.PurchaseContex
}
}

// temporary until we have a proper ownership check service (WIP)
public boolean validateAccess(PurchaseContext purchaseContext, Principal principal) {
var username = Objects.requireNonNull(principal).getName();
return organizationRepository.findOrganizationForUser(username, purchaseContext.getOrganizationId()).isPresent();
}

Optional<? extends PurchaseContext> findById(PurchaseContext.PurchaseContextType purchaseContextType, String idAsString) {
switch (purchaseContextType) {
case event: return eventRepository.findOptionalById(Integer.parseInt(idAsString));
Expand Down
45 changes: 41 additions & 4 deletions src/main/java/alfio/manager/PurchaseContextSearchManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@

import alfio.model.Event;
import alfio.model.PurchaseContext;
import alfio.model.ReservationPaymentDetail;
import alfio.model.TicketReservation;
import alfio.model.subscription.SubscriptionDescriptor;
import alfio.model.transaction.PaymentProxy;
import alfio.repository.TicketSearchRepository;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
Expand All @@ -28,7 +30,9 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.stream.Collectors;

import static java.util.stream.Collectors.toList;

Expand All @@ -37,22 +41,55 @@
@AllArgsConstructor
public class PurchaseContextSearchManager {

private static final int PAGE_SIZE = 50;
private static final List<String> SUPPORTED_PAYMENT_METHODS = EnumSet.complementOf(EnumSet.of(PaymentProxy.NONE, PaymentProxy.ADMIN))
.stream()
.map(PaymentProxy::name)
.collect(Collectors.toUnmodifiableList());
private final TicketSearchRepository ticketSearchRepository;

public Pair<List<TicketReservation>, Integer> findAllReservationsFor(PurchaseContext purchaseContext, Integer page, String search, List<TicketReservation.TicketReservationStatus> status) {
final int pageSize = 50;
int offset = page == null ? 0 : page * pageSize;
int offset = page == null ? 0 : page * PAGE_SIZE;
String toSearch = StringUtils.trimToNull(search);
toSearch = toSearch == null ? null : ("%" + toSearch + "%");
List<String> toFilter = (status == null || status.isEmpty() ? Arrays.asList(TicketReservation.TicketReservationStatus.values()) : status).stream().map(TicketReservation.TicketReservationStatus::toString).collect(toList());
if(purchaseContext.ofType(PurchaseContext.PurchaseContextType.event)) {
var event = (Event)purchaseContext;
List<TicketReservation> reservationsForEvent = ticketSearchRepository.findReservationsForEvent(event.getId(), offset, pageSize, toSearch, toFilter);
List<TicketReservation> reservationsForEvent = ticketSearchRepository.findReservationsForEvent(event.getId(), offset, PAGE_SIZE, toSearch, toFilter);
return Pair.of(reservationsForEvent, ticketSearchRepository.countReservationsForEvent(event.getId(), toSearch, toFilter));
} else {
var subscription = (SubscriptionDescriptor) purchaseContext;
List<TicketReservation> reservationsForSubscription = ticketSearchRepository.findReservationsForSubscription(subscription.getId(), offset, pageSize, toSearch, toFilter);
List<TicketReservation> reservationsForSubscription = ticketSearchRepository.findReservationsForSubscription(subscription.getId(), offset, PAGE_SIZE, toSearch, toFilter);
return Pair.of(reservationsForSubscription, ticketSearchRepository.countReservationsForSubscription(subscription.getId(), toSearch, toFilter));
}
}

public Pair<List<ReservationPaymentDetail>, Integer> findAllPaymentsFor(PurchaseContext purchaseContext, Integer page, String search) {
int offset = page == null ? 0 : page * PAGE_SIZE;
String toSearch = StringUtils.trimToNull(search);
toSearch = toSearch == null ? null : ("%" + toSearch + "%");
var toFilter = List.of(TicketReservation.TicketReservationStatus.COMPLETE.name());

if(purchaseContext.ofType(PurchaseContext.PurchaseContextType.event)) {
var event = (Event)purchaseContext;
List<ReservationPaymentDetail> reservationsForEvent = ticketSearchRepository.findAllPaymentsForEvent(event.getId(), offset, PAGE_SIZE, toSearch, toFilter, SUPPORTED_PAYMENT_METHODS);
return Pair.of(reservationsForEvent, ticketSearchRepository.countConfirmedPaymentsForEvent(event.getId(), toSearch, toFilter, SUPPORTED_PAYMENT_METHODS));
} else {
// functionality is not yet available for subscriptions
throw new UnsupportedOperationException("not implemented");
}
}

public List<ReservationPaymentDetail> findAllPaymentsForExport(PurchaseContext purchaseContext, String search) {
String toSearch = StringUtils.trimToNull(search);
toSearch = toSearch == null ? null : ("%" + toSearch + "%");
var toFilter = List.of(TicketReservation.TicketReservationStatus.COMPLETE.name());
if(purchaseContext.ofType(PurchaseContext.PurchaseContextType.event)) {
var event = (Event)purchaseContext;
return ticketSearchRepository.findAllEventPaymentsForExport(event.getId(), toSearch, toFilter, SUPPORTED_PAYMENT_METHODS);
} else {
// functionality is not yet available for subscriptions
throw new UnsupportedOperationException("not implemented");
}
}
}
Loading

0 comments on commit 51c2eda

Please sign in to comment.