Fare engine added

This commit is contained in:
Roba Boru
2026-05-26 16:25:24 +03:00
parent 80cea364c7
commit 769294d12f
12 changed files with 570 additions and 45 deletions

View File

@@ -0,0 +1,224 @@
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';
const TAX_RATE = 0.05;
@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');
if (originStop.sequence >= destStop.sequence)
throw new BadRequestException('Origin must come before destination in the route sequence');
const legStops = route.stops.filter(
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
);
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
if (missingDistance.length > 0)
throw new BadRequestException(
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
);
const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
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 ratePerKmMinor = seatClass.basePrice;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const paidChildrenCount = Math.max(0, childCount - 1);
const subtotalMinor =
baseFarePerPassengerMinor * adultCount +
baseFarePerPassengerMinor * paidChildrenCount;
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 afterDiscountMinor = subtotalMinor - discountMinor;
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
const totalEtbMinor = afterDiscountMinor + taxMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
const [originStation, destStation] = await Promise.all([
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
]);
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`,
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
`Subtotal: ${subtotalMinor} ETB minor`,
`Promo: ${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`,
].join('\n');
return {
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
seatClassName: seatClass.name,
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
adultCount,
childCount,
freeChildrenCount,
paidChildrenCount,
subtotalMinor,
discountMinor,
taxMinor,
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: { basePrice: '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);
}
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
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,
});
}
/** Calculate fares for all active seat classes on a schedule. */
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');
// ── Route-based calculation (fare engine) ────────────────────────────────
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
});
const results = await Promise.all(
seatClasses.map(sc =>
this.calculate({
routeId: schedule.routeId!,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId: sc.id,
nationality,
}).catch(() => null),
),
);
return results.filter(Boolean);
}
// ── Fallback: FareRule records scoped to this schedule ───────────────────
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
tripId: scheduleId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
include: { seatClass: true },
orderBy: { seatClass: { basePrice: '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: rule.seatClass.name,
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.',
);
}
}