Files
edr-platform/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts

386 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
import { Currency } from '@prisma/client';
@Injectable()
export class FareEngineService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async calculate(dto: FareCalculateDto) {
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) throw new NotFoundException('Route not found');
const originStop = route.stops.find(s => s.stationId === dto.originStationId);
const destStop = route.stops.find(s => s.stationId === dto.destinationStationId);
if (!originStop) throw new BadRequestException('Origin station not found on this route');
if (!destStop) throw new BadRequestException('Destination station not found on this route');
// Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip
// return leg traverses the same route high→low (e.g. C→A), so we price the segment by its
// absolute distance rather than rejecting the reverse order.
if (originStop.sequence === destStop.sequence)
throw new BadRequestException('Origin and destination must be different stops on this route');
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
where: {
coachTypeId: seatClass.coachTypeId,
nationalityType,
bedPosition: seatClass.bedPosition ?? null,
isActive: true,
},
}) ?? seatClass;
const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!);
if (totalDistanceKm <= 0 || isNaN(totalDistanceKm))
throw new BadRequestException('Invalid distance calculation - check route stop distances');
const now = new Date();
const [originStation, destStation] = await Promise.all([
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
]);
const segmentRoute = originStation && destStation
? `${originStation.code}-${destStation.code}` : null;
const fullRoute = `${route.code}`;
const fareRuleCandidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: dto.seatClassId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
const fareRule = this.pickBestFareRule(
fareRuleCandidates,
dto.scheduleId,
segmentRoute,
fullRoute,
dto.nationality,
);
let baseFarePerPassengerMinor: number;
let ratePerKmMinor: number;
let fareSource: string;
let insuranceFactor = 1;
let usdToEtbRate = 1;
// When insuranceFeeMinor is used as a multiplier in the formula it must not
// be added again as a flat fee. This flag tracks that.
let insuranceAlreadyInBase = false;
const segmentOverride = await this.prisma.segmentFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: dto.seatClassId,
originStopSequence: originStop.sequence,
destinationStopSequence: destStop.sequence,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
nationality: dto.nationality ?? null,
},
}) ?? await this.prisma.segmentFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: dto.seatClassId,
originStopSequence: originStop.sequence,
destinationStopSequence: destStop.sequence,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
nationality: null,
},
});
// Route-level fare override: checked after segment (most specific) but before
// schedule-scoped rules and the global seat-class tariff (least specific).
const routeFareOverride = segmentOverride ? null : await this.prisma.routeFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: nationalitySeatClass.id,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
orderBy: { validFrom: 'desc' },
});
if (segmentOverride) {
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
baseFarePerPassengerMinor *= 2;
}
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
} else if (routeFareOverride) {
// Stored as a per-km rate (same unit as SeatClass.baseFareMinor × 100).
// Insurance factor and USD→ETB conversion are applied identically to the
// global seat-class formula so the override is a pure rate substitution.
const ratePerKmEtb = routeFareOverride.baseFareMinor / 100;
insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
? nationalitySeatClass.insuranceFeeMinor / 100 : 1;
usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
ratePerKmMinor = routeFareOverride.baseFareMinor;
baseFarePerPassengerMinor = Math.round(
totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
);
fareSource = 'ROUTE_FARE_OVERRIDE';
insuranceAlreadyInBase = true;
} else if (fareRule?.tripId) {
baseFarePerPassengerMinor = fareRule.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
baseFarePerPassengerMinor *= 2;
}
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SCHEDULE_FARE_RULE';
} else {
// Distance-based formula:
// baseFare (minor) = distanceKm × (baseFareMinor / 100) × insuranceFactor × usdToEtbRate
// baseFareMinor stored as integer (e.g. 300 = 3.00 ETB/km), divided by 100 to get ETB/km.
// insuranceFeeMinor stored as integer (e.g. 102 = 1.02 multiplier), divided by 100; defaults to 1 if unset.
// usdToEtbRate fetched live from CurrencyExchangeRate table.
// Insurance is already baked into baseFarePerPassengerMinor — do NOT add it again as a flat fee.
const ratePerKmEtb = nationalitySeatClass.baseFareMinor / 100;
insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
? nationalitySeatClass.insuranceFeeMinor / 100
: 1;
usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
baseFarePerPassengerMinor = Math.round(
totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
);
fareSource = 'SEAT_CLASS_BASE_FARE';
insuranceAlreadyInBase = true;
}
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
const insurancePerPassenger = insuranceAlreadyInBase ? 0 : (seatClass.insuranceFeeMinor ?? 0);
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
const adultSubtotal = farePerPassengerMinor * adultCount;
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
let discountMinor = 0;
let promoLabel = 'none';
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff
? Math.round(subtotalMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
promoLabel = `${dto.promoCode} (-${promo.percentOff ?? 0}%)`;
}
}
const totalEtbMinor = subtotalMinor - discountMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''}${nationalitySeatClass.name}`,
`Rate per km: ${routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor} minor → ${(routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor) / 100} ETB/km${routeFareOverride ? ' [ROUTE OVERRIDE]' : ''}`,
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
`USD→ETB rate: ${usdToEtbRate}`,
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
`Fare source: ${fareSource}`,
].join('\n');
return {
fareSource,
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
seatClassId: nationalitySeatClass.id,
seatClassName: nationalitySeatClass.name,
totalDistanceKm,
ratePerKmMinor,
insuranceFactor,
usdToEtbRate,
baseFarePerPassengerMinor,
premiumPerPassenger,
insurancePerPassenger,
farePerPassengerMinor,
adultCount,
childCount,
freeChildrenCount,
paidChildrenCount,
subtotalMinor,
discountMinor,
totalMinor: totalEtbMinor,
billingCurrency,
totalInBillingCurrency,
exchangeRate,
calculation,
};
}
async compareClasses(
routeId: string,
originStationId: string,
destinationStationId: string,
nationality?: string,
adultCount = 1,
childCount = 0,
) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { baseFareMinor: 'asc' },
});
const results = await Promise.all(
seatClasses.map(sc =>
this.calculate({ routeId, originStationId, destinationStationId, seatClassId: sc.id, nationality, adultCount, childCount })
.catch(() => null),
),
);
return results.filter(Boolean);
}
private pickBestFareRule(
candidates: any[],
scheduleId?: string,
segmentRoute?: string | null,
fullRoute?: string,
nationality?: string,
): any | null {
const nat = nationality ?? null;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality: nat },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality: nat },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality: nat },
{ tripId: scheduleId, route: null, nationality: null },
{ tripId: null, route: segmentRoute, nationality: nat },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality: nat },
{ tripId: null, route: fullRoute, nationality: null },
{ tripId: null, route: null, nationality: nat },
{ tripId: null, route: null, nationality: null },
];
for (const p of priorities) {
const match = candidates.find(
c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality,
);
if (match) return match;
}
return null;
}
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
return this.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
scheduleId,
});
}
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { baseFareMinor: 'asc' },
});
const results = await Promise.all(
seatClasses.map(sc =>
this.calculate({
routeId: schedule.routeId!,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId: sc.id,
nationality,
scheduleId,
}).catch(() => null),
),
);
return results.filter(Boolean);
}
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
tripId: scheduleId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
});
if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => ({
seatClassId: rule.seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
}));
}
throw new BadRequestException(
'Schedule has no associated route and no fare rules. Assign a route or create fare rules for this schedule.',
);
}
}