Skip to content

Commit

Permalink
initial work for supporting "percentage fee" additional items
Browse files Browse the repository at this point in the history
  • Loading branch information
cbellone committed May 18, 2024
1 parent 1eb4e30 commit cfcccb4
Show file tree
Hide file tree
Showing 17 changed files with 3,228 additions and 2,821 deletions.
126 changes: 98 additions & 28 deletions src/main/java/alfio/manager/AdditionalServiceManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import alfio.controller.form.AdditionalServiceLinkForm;
import alfio.manager.support.reservation.NotEnoughItemsException;
import alfio.manager.support.reservation.ReservationCostCalculator;
import alfio.model.*;
import alfio.model.decorator.AdditionalServicePriceContainer;
import alfio.model.modification.ASReservationWithOptionalCodeModification;
Expand All @@ -43,12 +44,15 @@
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static alfio.model.AdditionalService.SupplementPolicy.MANDATORY_ONE_FOR_TICKET;
import static alfio.util.MonetaryUtil.unitToCents;
import static alfio.model.AdditionalService.SupplementPolicy.*;
import static alfio.util.MonetaryUtil.*;
import static java.util.Objects.requireNonNull;
import static java.util.Objects.requireNonNullElse;
import static java.util.stream.Collectors.groupingBy;

@Component
@AllArgsConstructor
Expand All @@ -62,6 +66,7 @@ public class AdditionalServiceManager {
private final NamedParameterJdbcTemplate jdbcTemplate;
private final TicketRepository ticketRepository;
private final PurchaseContextFieldRepository purchaseContextFieldRepository;
private final ReservationCostCalculator reservationCostCalculator;


public List<AdditionalService> loadAllForEvent(int eventId) {
Expand Down Expand Up @@ -127,7 +132,7 @@ public List<AdditionalServiceItemExport> exportItemsForEvent(AdditionalService.A
public EventModification.AdditionalService insertAdditionalService(Event event, EventModification.AdditionalService additionalService) {
int eventId = event.getId();
AffectedRowCountAndKey<Integer> result = additionalServiceRepository.insert(eventId,
Optional.ofNullable(additionalService.getPrice()).map(p -> MonetaryUtil.unitToCents(p, event.getCurrency())).orElse(0),
evaluateAdditionalServicePriceCts(additionalService, event.getCurrency()),
additionalService.isFixPrice(),
additionalService.getOrdinal(),
additionalService.getAvailableQuantity(),
Expand All @@ -137,7 +142,9 @@ public EventModification.AdditionalService insertAdditionalService(Event event,
additionalService.getVat(),
additionalService.getVatType(),
additionalService.getType(),
additionalService.getSupplementPolicy());
additionalService.getSupplementPolicy(),
additionalService.getMinPrice() != null ? MonetaryUtil.unitToCents(additionalService.getMinPrice(), event.getCurrency()) : null,
additionalService.getMaxPrice() != null ? MonetaryUtil.unitToCents(additionalService.getMaxPrice(), event.getCurrency()) : null);
Validate.isTrue(result.getAffectedRowCount() == 1, "too many records updated");
int id = result.getKey();
Stream.concat(additionalService.getTitle().stream(), additionalService.getDescription().stream()).
Expand All @@ -159,7 +166,7 @@ void createAllAdditionalServices(Event event, List<EventModification.AdditionalS
var zoneId = event.getZoneId();
additionalServices.forEach(as -> {
AffectedRowCountAndKey<Integer> service = additionalServiceRepository.insert(eventId,
Optional.ofNullable(as.getPrice()).map(p -> MonetaryUtil.unitToCents(p, currencyCode)).orElse(0),
evaluateAdditionalServicePriceCts(as, currencyCode),
as.isFixPrice(),
as.getOrdinal(),
as.getAvailableQuantity(),
Expand All @@ -169,7 +176,9 @@ void createAllAdditionalServices(Event event, List<EventModification.AdditionalS
as.getVat(),
as.getVatType(),
as.getType(),
as.getSupplementPolicy());
as.getSupplementPolicy(),
as.getMinPrice() != null ? MonetaryUtil.unitToCents(as.getMinPrice(), currencyCode) : null,
as.getMaxPrice() != null ? MonetaryUtil.unitToCents(as.getMaxPrice(), currencyCode) : null);
if (as.getAvailableQuantity() > 0) {
preGenerateItems(service.getKey(), event, as);
}
Expand All @@ -179,6 +188,14 @@ void createAllAdditionalServices(Event event, List<EventModification.AdditionalS
}
}

private static int evaluateAdditionalServicePriceCts(EventModification.AdditionalService as, String currencyCode) {
if (as.getSupplementPolicy() == MANDATORY_PERCENTAGE_FOR_TICKET || as.getSupplementPolicy() == MANDATORY_PERCENTAGE_RESERVATION) {
return Objects.requireNonNullElse(as.getPrice(), BigDecimal.ZERO).intValueExact();
} else {
return as.getPrice() != null ? MonetaryUtil.unitToCents(as.getPrice(), currencyCode) : 0;
}
}

private void preGenerateItems(int serviceId, Event event, EventModification.AdditionalService as) {
if (!event.supportsLinkedAdditionalServices()) {
LOGGER.trace("Event does not support linked additional services");
Expand Down Expand Up @@ -253,7 +270,7 @@ void bookAdditionalServiceItems(int quantity,
reservationId,
AdditionalServiceItem.AdditionalServiceItemStatus.PENDING,
event,
as.getSrcPriceCts(),
pc.getSrcPriceCts(),
unitToCents(pc.getFinalPrice(), currencyCode),
unitToCents(pc.getVAT(), currencyCode),
unitToCents(pc.getAppliedDiscount(), currencyCode)
Expand Down Expand Up @@ -383,13 +400,13 @@ public void bookAdditionalServicesForReservation(Event event,
// apply valid additional service with supplement policy mandatory one for ticket
var additionalServicesForEvent = loadAllForEvent(event.getId());

var automatic = additionalServicesForEvent.stream().filter(as -> as.getSupplementPolicy() == MANDATORY_ONE_FOR_TICKET && as.getSaleable())
var automatic = additionalServicesForEvent.stream().filter(as -> as.getSupplementPolicy().isMandatory() && as.getSaleable())
.map(as -> {
AdditionalServiceReservationModification asrm = new AdditionalServiceReservationModification();
asrm.setAdditionalServiceId(as.getId());
asrm.setQuantity(ticketCount);
asrm.setQuantity(as.getSupplementPolicy() == MANDATORY_ONE_FOR_TICKET ? ticketCount : 1);
return new ASReservationWithOptionalCodeModification(asrm, Optional.empty());
}).collect(Collectors.toList());
}).toList();

if (automatic.isEmpty() && additionalServices.isEmpty()) {
// skip additional queries
Expand Down Expand Up @@ -436,37 +453,54 @@ private void reserveAdditionalServicesForReservation(Event event,
PromoCodeDiscount discount,
List<AdditionalService> additionalServicesForEvent,
List<Integer> ticketIds) {
additionalServiceReservationList.forEach(additionalServiceReservation -> {
if (additionalServiceReservation.getAdditionalServiceId() == null) {
return;
}
var optionalAs = additionalServicesForEvent.stream()
.filter(as -> as.getId() == additionalServiceReservation.getAdditionalServiceId())
.findFirst();
if (optionalAs.isEmpty()) {
return;
}
var as = optionalAs.get();
if (additionalServiceReservation.getQuantity() > 0 && (as.isFixPrice() || requireNonNullElse(additionalServiceReservation.getAmount(), BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0)) {


var allAdditionalItems = additionalServiceReservationList.stream()
.filter(ar -> ar.getAdditionalServiceId() != null)
.map(requested -> {
var optionalAs = additionalServicesForEvent.stream()
.filter(as -> as.getId() == requested.getAdditionalServiceId() && as.getSupplementPolicy() != null)
.findFirst();
return new MappedRequestedService(requested, optionalAs.orElse(null));
})
.filter(o -> Objects.nonNull(o.additionalService))
.collect(groupingBy(o -> o.additionalService.getSupplementPolicy()));

// first handle MANDATORY_PERCENTAGE_FOR_TICKET, if any.
// this way only ticket costs will be included in the percentage calculation
handleMandatoryPercentage(MANDATORY_PERCENTAGE_FOR_TICKET, event, reservationId, discount, allAdditionalItems);

// then apply all non-mandatory (i.e. user-selected)
var nonMandatoryPolicies = AdditionalService.SupplementPolicy.userSelected();
nonMandatoryPolicies.stream()
.filter(allAdditionalItems::containsKey)
.flatMap(p -> allAdditionalItems.get(p).stream().filter(as -> as.requested.getQuantity() > 0 && (as.additionalService.isFixPrice() || requireNonNullElse(as.requested.getAmount(), BigDecimal.ZERO).compareTo(BigDecimal.ZERO) > 0)))
.forEach(mapped -> {
var as = mapped.additionalService;
var additionalServiceReservation = mapped.requested;
bookAdditionalServiceItems(additionalServiceReservation.getQuantity(), additionalServiceReservation.getAmount(), as, event, discount, reservationId);
}
});

// as last step, we apply all remaining mandatory
handleMandatoryPercentage(MANDATORY_PERCENTAGE_RESERVATION, event, reservationId, discount, allAdditionalItems);

allAdditionalItems.getOrDefault(MANDATORY_ONE_FOR_TICKET, List.of()).forEach(mrs -> {
BigDecimal amount = mrs.requested.getAmount();
bookAdditionalServiceItems(mrs.requested.getQuantity(), amount, mrs.additionalService, event, discount, reservationId);
});

// link additional services to tickets
var bookedItems = additionalServiceItemRepository.findByReservationUuid(event.getId(), reservationId);
//we skip donation as they don't have a supplement policy
var byPolicy = additionalServicesForEvent.stream()
.filter(as -> as.getSupplementPolicy() != null && additionalServiceReservationList.stream().anyMatch(findAdditionalServiceRequest(as)))
.collect(Collectors.groupingBy(AdditionalService::getSupplementPolicy));

var parameterSources = byPolicy.entrySet().stream()
var parameterSources = allAdditionalItems.entrySet().stream()
.flatMap(entry -> {
var values = entry.getValue();
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("Processing {} items with policy {}", values.size(), entry.getKey());
}
return values.stream()
.flatMap(m -> linkWithEveryTicket(reservationId, additionalServiceReservationList, bookedItems, ticketIds, m));
.flatMap(m -> linkWithEveryTicket(reservationId, additionalServiceReservationList, bookedItems, ticketIds, m.additionalService));
}).toArray(MapSqlParameterSource[]::new);
var results = jdbcTemplate.batchUpdate(additionalServiceItemRepository.batchLinkToTicket(), parameterSources);
Validate.isTrue(Arrays.stream(results).allMatch(i -> i == 1));
Expand All @@ -481,6 +515,39 @@ private void reserveAdditionalServicesForReservation(Event event,
Validate.isTrue(Arrays.stream(noPolicyResults).allMatch(i -> i == 1));
}

private void handleMandatoryPercentage(AdditionalService.SupplementPolicy supplementPolicy,
Event event,
String reservationId,
PromoCodeDiscount discount,
Map<AdditionalService.SupplementPolicy, List<MappedRequestedService>> allMapped) {
if (allMapped.containsKey(supplementPolicy)) {
final TotalPrice reservationPrice = reservationCostCalculator.totalReservationCostWithVAT(reservationId).getKey();
allMapped.get(supplementPolicy).forEach(mrs -> {
int basePrice = reservationPrice.getPriceWithVAT();
var vatStatus = event.getVatStatus();
if (PriceContainer.VatStatus.isVatNotIncluded(vatStatus)) {
basePrice -= reservationPrice.getVAT();
}
int percentage = mrs.additionalService.getSrcPriceCts();
int amountCts = adjustUsingMinMaxPrice(calcPercentage(basePrice, new BigDecimal(String.valueOf(percentage)), BigDecimal::intValueExact), mrs.additionalService);
BigDecimal amount = centsToUnit(amountCts, reservationPrice.getCurrencyCode());
bookAdditionalServiceItems(mrs.requested.getQuantity(), amount, mrs.additionalService, event, discount, reservationId);
});
}
}

private static int adjustUsingMinMaxPrice(int amountCts, AdditionalService additionalService) {
if (additionalService.getMinPrice() != null && unitToCents(additionalService.getMinPrice(), additionalService.getCurrencyCode()) > amountCts) {
// if calculated price is below minimum, we return the minimum price
return unitToCents(additionalService.getMinPrice(), additionalService.getCurrencyCode());
}
if (additionalService.getMaxPrice() != null && unitToCents(additionalService.getMaxPrice(), additionalService.getCurrencyCode()) < amountCts) {
// if calculated price is over maximum, we return the maximum price
return unitToCents(additionalService.getMaxPrice(), additionalService.getCurrencyCode());
}
return amountCts;
}

private static Stream<MapSqlParameterSource> linkWithEveryTicket(String reservationId, List<ASReservationWithOptionalCodeModification> additionalServiceReservationList, List<AdditionalServiceItem> bookedItems, List<Integer> ticketIds, AdditionalService m) {
var additionalServiceRequest = additionalServiceReservationList.stream()
.filter(findAdditionalServiceRequest(m))
Expand All @@ -495,4 +562,7 @@ private static Stream<MapSqlParameterSource> linkWithEveryTicket(String reservat
private static Predicate<ASReservationWithOptionalCodeModification> findAdditionalServiceRequest(AdditionalService as) {
return asr -> as.getId() == asr.getAdditionalServiceId();
}

record MappedRequestedService(ASReservationWithOptionalCodeModification requested, AdditionalService additionalService) {
}
}
5 changes: 4 additions & 1 deletion src/main/java/alfio/manager/PurchaseContextFieldManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ private Integer findAdditionalService(Event event, EventModification.AdditionalS
Optional.ofNullable(as.getPrice()).map(p -> MonetaryUtil.unitToCents(p, currencyCode)).orElse(0),
as.getType(),
as.getSupplementPolicy(),
currencyCode, null).getChecksum();
currencyCode,
null,
as.getMinPrice() != null ? MonetaryUtil.unitToCents(as.getMinPrice(), currencyCode) : null,
as.getMaxPrice() != null ? MonetaryUtil.unitToCents(as.getMaxPrice(), currencyCode) : null).getChecksum();
return additionalServiceRepository.loadAllForEvent(eventId).stream().filter(as1 -> as1.getChecksum().equals(checksum)).findFirst().map(AdditionalService::getId).orElse(null);
}

Expand Down
57 changes: 55 additions & 2 deletions src/main/java/alfio/model/AdditionalService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package alfio.model;

import alfio.util.ClockProvider;
import alfio.util.MonetaryUtil;
import ch.digitalfondue.npjt.ConstructorAnnotationRowMapper.Column;
import lombok.Getter;
import org.springframework.security.crypto.codec.Hex;
Expand All @@ -28,7 +29,11 @@
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;

@Getter
public class AdditionalService {
Expand All @@ -46,7 +51,15 @@ public enum AdditionalServiceType {
}

public enum SupplementPolicy {
MANDATORY_ONE_FOR_TICKET,
MANDATORY_ONE_FOR_TICKET(true),
/**
* Will be calculated from the total reservation price, excluding any other mandatory fee
*/
MANDATORY_PERCENTAGE_RESERVATION(true),
/**
* Will be calculated from the total price of the tickets, excluding any additional items
*/
MANDATORY_PERCENTAGE_FOR_TICKET(true),
OPTIONAL_UNLIMITED_AMOUNT,
OPTIONAL_MAX_AMOUNT_PER_TICKET {
@Override
Expand All @@ -61,9 +74,28 @@ public boolean isValid(int quantity, AdditionalService as, int selectionCount) {
}
};

private final boolean mandatory;

SupplementPolicy() {
this(false);
}

SupplementPolicy(boolean mandatory) {
this.mandatory = mandatory;
}


public boolean isValid(int quantity, AdditionalService as, int selectionCount) {
return true;
}

public boolean isMandatory() {
return mandatory;
}

public static Set<SupplementPolicy> userSelected() {
return Arrays.stream(values()).filter(Predicate.not(SupplementPolicy::isMandatory)).collect(Collectors.toSet());
}
}

private final int id;
Expand All @@ -83,6 +115,9 @@ public boolean isValid(int quantity, AdditionalService as, int selectionCount) {
private final String currencyCode;
private final Integer availableItems;

private final Integer minPriceCts;
private final Integer maxPriceCts;

public AdditionalService(@Column("id") int id,
@Column("event_id_fk") int eventId,
@Column("fix_price") boolean fixPrice,
Expand All @@ -97,7 +132,9 @@ public AdditionalService(@Column("id") int id,
@Column("service_type") AdditionalServiceType type,
@Column("supplement_policy") SupplementPolicy supplementPolicy,
@Column("currency_code") String currencyCode,
@Column("available_count") Integer availableItems) {
@Column("available_count") Integer availableItems,
@Column("price_min_cts") Integer minPriceCts,
@Column("price_max_cts") Integer maxPriceCts) {
this.id = id;
this.eventId = eventId;
this.fixPrice = fixPrice;
Expand All @@ -113,6 +150,8 @@ public AdditionalService(@Column("id") int id,
this.supplementPolicy = supplementPolicy;
this.currencyCode = currencyCode;
this.availableItems = availableItems;
this.minPriceCts = minPriceCts;
this.maxPriceCts = maxPriceCts;
}

public ZonedDateTime getInception(ZoneId zoneId) {
Expand All @@ -128,6 +167,20 @@ public boolean getSaleable() {
return getUtcInception().isBefore(now) && getUtcExpiration().isAfter(now);
}

public BigDecimal getMinPrice() {
if (minPriceCts != null) {
return MonetaryUtil.centsToUnit(minPriceCts, currencyCode);
}
return null;
}

public BigDecimal getMaxPrice() {
if (maxPriceCts != null) {
return MonetaryUtil.centsToUnit(maxPriceCts, currencyCode);
}
return null;
}

public String getChecksum() {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Expand Down
Loading

0 comments on commit cfcccb4

Please sign in to comment.