mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
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.',
|
||
);
|
||
}
|
||
}
|