Fare and route-coach, production checklist updates

This commit is contained in:
Stephanos A
2026-07-02 22:42:15 +03:00
parent 4aadf588d4
commit 200476dfd6
37 changed files with 1672 additions and 248 deletions

View File

@@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service';
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
import { Currency } from '@prisma/client';
const TAX_RATE = 0.05;
@Injectable()
export class FareEngineService {
constructor(
@@ -32,6 +30,22 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
// Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Find the nationality-specific seat class for the same coach type and bed position.
// Falls back to the requested seatClass if no nationality-specific one exists.
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
where: {
coachTypeId: seatClass.coachTypeId,
nationalityType,
bedPosition: seatClass.bedPosition ?? null,
isActive: true,
},
}) ?? seatClass;
// Calculate distance: distanceKm represents cumulative distance from route origin
// For a segment, distance = destination.distanceKm - origin.distanceKm
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
@@ -102,9 +116,10 @@ export class FareEngineService {
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE';
} else {
// Default: distance-based using live SeatClass rate
ratePerKmMinor = seatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm);
// Default: distance-based using tariff formula: km × rate × 1.02
// baseFareMinor stores the per-km rate (tariff decimal × 100000)
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
fareSource = 'SEAT_CLASS_BASE_FARE';
}
@@ -138,8 +153,7 @@ export class FareEngineService {
}
const afterDiscountMinor = subtotalMinor - discountMinor;
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
const totalEtbMinor = afterDiscountMinor + taxMinor;
const totalEtbMinor = afterDiscountMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
@@ -147,8 +161,9 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalitySeatClass.name}`,
`Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
@@ -160,10 +175,8 @@ export class FareEngineService {
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
`Fare source: ${fareSource}`,
@@ -174,8 +187,8 @@ export class FareEngineService {
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
seatClassId: seatClass.id,
seatClassName: seatClass.name,
seatClassId: nationalitySeatClass.id,
seatClassName: nationalitySeatClass.name,
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
@@ -188,7 +201,6 @@ export class FareEngineService {
paidChildrenCount,
subtotalMinor,
discountMinor,
taxMinor,
totalMinor: totalEtbMinor,
billingCurrency,
totalInBillingCurrency,
@@ -313,16 +325,13 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
const totalMinor = rule.baseFareMinor + taxMinor;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
taxMinor,
totalMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};