RentalCostCalculationService.java

package com.github.jenkaby.bikerental.tariff.application.service;

import com.github.jenkaby.bikerental.shared.config.RentalProperties;
import com.github.jenkaby.bikerental.shared.domain.model.vo.DiscountPercent;
import com.github.jenkaby.bikerental.shared.domain.model.vo.Money;
import com.github.jenkaby.bikerental.tariff.*;
import com.github.jenkaby.bikerental.tariff.application.usecase.RentalCostCalculationUseCase;
import com.github.jenkaby.bikerental.tariff.application.usecase.SelectTariffV2UseCase;
import com.github.jenkaby.bikerental.tariff.domain.exception.InvalidSpecialPriceException;
import com.github.jenkaby.bikerental.tariff.domain.model.PricingType;
import com.github.jenkaby.bikerental.tariff.domain.model.TariffV2;
import com.github.jenkaby.bikerental.tariff.domain.repository.TariffV2Repository;
import com.github.jenkaby.bikerental.tariff.domain.service.BaseEquipmentCostBreakdown;
import com.github.jenkaby.bikerental.tariff.domain.service.BaseRentalCostCalculationResult;
import org.springframework.stereotype.Service;

import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.util.*;

@Service
class RentalCostCalculationService implements RentalCostCalculationUseCase {

    private final RentalProperties rentalProperties;
    private final TariffV2Repository tariffRepository;
    private final SelectTariffV2UseCase selectTariffUseCase;
    private final Clock clock;

    RentalCostCalculationService(RentalProperties rentalProperties,
                                 TariffV2Repository tariffRepository,
                                 SelectTariffV2UseCase selectTariffUseCase,
                                 Clock clock) {
        this.rentalProperties = rentalProperties;
        this.tariffRepository = tariffRepository;
        this.selectTariffUseCase = selectTariffUseCase;
        this.clock = clock;
    }

    @Override
    public RentalCostCalculationResult execute(RentalCostCalculationCommand command) {
        if (command.specialTariffId() != null) {
            return executeSpecialMode(command);
        }
        return executeNormalMode(command);
    }

    private RentalCostCalculationResult executeSpecialMode(RentalCostCalculationCommand command) {
        var specialTariff = tariffRepository.get(command.specialTariffId());
        if (specialTariff.getPricingType() != PricingType.SPECIAL) {
            throw new InvalidSpecialTariffTypeException(command.specialTariffId(), specialTariff.getPricingType());
        }
        Money totalCost = command.specialPrice();
        if (totalCost.isNegative()) {
            throw new InvalidSpecialPriceException();
        }

        List<EquipmentCostBreakdown> breakdowns = new ArrayList<>();
        Duration effective = command.effectiveDuration();
        for (EquipmentCostItem item : command.equipments()) {
            breakdowns.add(new BaseEquipmentCostBreakdown(
                    item.equipmentType(),
                    command.specialTariffId(),
                    specialTariff.getName(),
                    PricingType.SPECIAL.name(),
                    Money.zero(),
                    effective,
                    Duration.ZERO,
                    Duration.ZERO,
                    new BreakdownCostDetails.SpecialGroup()
            ));
        }

        return new BaseRentalCostCalculationResult(
                breakdowns,
                totalCost,
                DiscountDetail.none(),
                totalCost,
                effective,
                command.actualDuration() == null,
                true
        );
    }

    private RentalCostCalculationResult executeNormalMode(RentalCostCalculationCommand command) {
        LocalDate rentalDate = Optional.ofNullable(command.rentalDate()).orElse(LocalDate.now(clock));
        Duration planned = command.plannedDuration();
        Duration actual = command.actualDuration();
        boolean estimate = actual == null;
        Duration effective = command.effectiveDuration();
        Duration billedDuration;
        Duration overtime;
        Duration forgiven;

        if (estimate) {
            billedDuration = planned;
            overtime = Duration.ZERO;
            forgiven = Duration.ZERO;
        } else {
            Duration overtimeDur = actual.minus(planned);
            if (overtimeDur.isNegative() || overtimeDur.isZero()) {
                billedDuration = actual;
                overtime = Duration.ZERO;
                forgiven = Duration.ZERO;
            } else {
                overtime = overtimeDur;
                int thresholdMinutes = rentalProperties.getForgivenessThresholdMinutes();
                long overtimeMinutes = overtimeDur.toMinutes();
                if (overtimeMinutes <= thresholdMinutes) {
                    billedDuration = planned;
                    forgiven = overtimeDur;
                } else {
                    billedDuration = actual;
                    forgiven = Duration.ZERO;
                }
            }
        }

        List<EquipmentCostBreakdown> breakdowns = new ArrayList<>();
        Money subtotal = Money.zero();
        Map<String, TariffV2> tariffCache = new HashMap<>();
        for (EquipmentCostItem item : command.equipments()) {
            TariffV2 tariff = tariffCache.computeIfAbsent(item.equipmentType(),
                    type -> selectTariffUseCase.execute(new SelectTariffV2UseCase.SelectTariffCommand(item.equipmentType(), billedDuration, rentalDate)));
            RentalCostV2 cost = tariff.calculateCost(billedDuration);

            breakdowns.add(new BaseEquipmentCostBreakdown(
                    item.equipmentType(),
                    tariff.getId(),
                    tariff.getName(),
                    tariff.getPricingType().name(),
                    cost.totalCost(),
                    billedDuration,
                    overtime,
                    forgiven,
                    cost.calculationBreakdown()
            ));
            subtotal = subtotal.add(cost.totalCost());
        }

        DiscountPercent discount = Optional.ofNullable(command.discount()).orElse(DiscountPercent.zero());
        Money discountAmount = discount.multiply(subtotal);
        Money totalCost = subtotal.subtract(discountAmount);

        return new BaseRentalCostCalculationResult(
                breakdowns,
                subtotal,
                new DiscountDetail(discount, discountAmount),
                totalCost,
                effective,
                estimate,
                false
        );
    }
}