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,54 @@
import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse } from '@nestjs/swagger';
import { CurrencyService } from '../currency/currency.service';
import { UpsertExchangeRateDto } from './currency.dto';
import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
class UpdateExchangeRateDto {
@ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
@ApiProperty({ example: 'MANUAL', required: false }) @IsOptional() @IsString() source?: string;
}
@ApiTags('Fare Engine')
@Controller('fare-engine/exchange-rates')
export class CurrencyController {
constructor(private currency: CurrencyService) {}
@Get()
@ApiOperation({ summary: 'List all exchange rates (latest per pair first)' })
list() {
return this.currency.listRates();
}
@Put()
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
upsert(@Body() dto: UpsertExchangeRateDto) {
return this.currency.upsertRate(dto.fromCurrency, dto.toCurrency, dto.rate, undefined, dto.source);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an exchange rate by ID' })
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
@ApiResponse({ status: 200, description: 'Rate updated' })
@ApiResponse({ status: 404, description: 'Rate not found' })
update(@Param('id') id: string, @Body() dto: UpdateExchangeRateDto) {
return this.currency.updateRateById(id, dto.rate, dto.source);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an exchange rate record by ID' })
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
@ApiResponse({ status: 200, description: 'Rate deleted' })
@ApiResponse({ status: 404, description: 'Rate not found' })
remove(@Param('id') id: string) {
return this.currency.deleteRate(id);
}
@Post('sync')
@ApiOperation({ summary: 'Trigger exchange rate sync from external provider' })
sync() {
return this.currency.syncExchangeRates();
}
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
export class UpsertExchangeRateDto {
@ApiProperty({ enum: Currency, example: 'ETB' }) @IsEnum(Currency) fromCurrency: Currency;
@ApiProperty({ enum: Currency, example: 'DJF' }) @IsEnum(Currency) toCurrency: Currency;
@ApiProperty({ example: 3.25 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
@ApiPropertyOptional({ example: 'MANUAL', description: 'Source label e.g. MANUAL, EXTERNAL_API' })
@IsOptional() @IsString() source?: string;
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { FareEngineService } from './fare-engine.service';
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
@ApiTags('Fare Engine')
@Controller('fare-engine')
export class FareEngineController {
constructor(private service: FareEngineService) {}
@Post('calculate')
@ApiOperation({
summary: 'Calculate fare for a journey leg',
description: `Computes fare using the formula:
**Fare = totalKm × ratePerKm × exchangeRate**
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
- \`ratePerKm\`\`SeatClass.basePrice\` (stored in ETB minor units per km)
- \`exchangeRate\` — derived from passenger nationality:
- **Ethiopian** → ETB (rate = 1.0)
- **Djiboutian** → DJF (rate ≈ 3.25)
- **Other / unspecified** → USD (rate ≈ 0.018)
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
5% tax applied after promo discount.
Returns a full breakdown including a human-readable calculation trace.`,
})
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
calculate(@Body() dto: FareCalculateDto) {
return this.service.calculate(dto);
}
@Get('compare')
@ApiOperation({
summary: 'Compare fares across all seat classes for a route leg',
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
})
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
compareClasses(
@Query('routeId') routeId: string,
@Query('originStationId') originStationId: string,
@Query('destinationStationId') destinationStationId: string,
@Query('nationality') nationality?: string,
@Query('adultCount') adultCount?: string,
@Query('childCount') childCount?: string,
) {
return this.service.compareClasses(
routeId,
originStationId,
destinationStationId,
nationality,
adultCount ? parseInt(adultCount) : 1,
childCount ? parseInt(childCount) : 0,
);
}
}

View File

@@ -0,0 +1,72 @@
import { IsString, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
// Nationality → home currency mapping
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
Ethiopian: Currency.ETB,
Djiboutian: Currency.DJF,
};
export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD;
}
export class FareCalculateDto {
@ApiProperty({ example: 'route-uuid', description: 'Route UUID — used to look up stop distances' })
@IsString() routeId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the route)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID (must come after origin)' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID — its basePrice is the per-km rate in ETB minor units' })
@IsString() seatClassId: string;
@ApiPropertyOptional({
example: 'Ethiopian',
description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.',
})
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({
example: 2,
description: 'Number of adult passengers (age ≥ 5). Defaults to 1.',
})
@IsOptional() @Type(() => Number) @IsInt() @Min(1) adultCount?: number;
@ApiPropertyOptional({
example: 1,
description: 'Number of child passengers (age < 5). First child travels free.',
})
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
@IsOptional() @IsString() promoCode?: string;
}
export class FareBreakdownDto {
@ApiProperty({ example: 'ADD-DJI' }) routeCode: string;
@ApiProperty({ example: 'Addis Ababa' }) originName: string;
@ApiProperty({ example: 'Djibouti' }) destinationName: string;
@ApiProperty({ example: 'Economy Regular' }) seatClassName: string;
@ApiProperty({ example: 756 }) totalDistanceKm: number;
@ApiProperty({ example: 120 }) ratePerKmMinor: number;
@ApiProperty({ example: 90720 }) baseFarePerPassengerMinor: number;
@ApiProperty({ example: 2 }) adultCount: number;
@ApiProperty({ example: 1 }) childCount: number;
@ApiProperty({ example: 1 }) freeChildrenCount: number;
@ApiProperty({ example: 0 }) paidChildrenCount: number;
@ApiProperty({ example: 181440 }) subtotalMinor: number;
@ApiProperty({ example: 0 }) discountMinor: number;
@ApiProperty({ example: 9072 }) taxMinor: number;
@ApiProperty({ example: 190512 }) totalMinor: number;
@ApiProperty({ example: 'ETB', enum: Currency }) billingCurrency: Currency;
@ApiProperty({ example: 190512 }) totalInBillingCurrency: number;
@ApiProperty({ example: 3.25 }) exchangeRate: number;
@ApiProperty({ description: 'Step-by-step calculation trace for transparency' }) calculation: string;
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FareEngineController } from './fare-engine.controller';
import { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [CurrencyModule],
controllers: [FareEngineController, CurrencyController],
providers: [FareEngineService],
exports: [FareEngineService],
})
export class FareEngineModule {}

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.',
);
}
}