From 769294d12f93dae00aa5682424e86bd7e4cd3016 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 26 May 2026 16:25:24 +0300 Subject: [PATCH] Fare engine added --- apps/edr-passenger-api/src/app.module.ts | 2 + apps/edr-passenger-api/src/main.ts | 1 + .../src/modules/currency/currency.service.ts | 63 +++-- .../fare-engine/currency.controller.ts | 54 +++++ .../src/modules/fare-engine/currency.dto.ts | 12 + .../fare-engine/fare-engine.controller.ts | 65 +++++ .../modules/fare-engine/fare-engine.dto.ts | 72 ++++++ .../modules/fare-engine/fare-engine.module.ts | 13 + .../fare-engine/fare-engine.service.ts | 224 ++++++++++++++++++ .../modules/schedules/schedules.controller.ts | 43 +++- .../src/modules/schedules/schedules.module.ts | 2 + .../modules/schedules/schedules.service.ts | 64 +++-- 12 files changed, 570 insertions(+), 45 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts create mode 100644 apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 261463e27..d0dfed771 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -35,6 +35,7 @@ import { AgentsModule } from './modules/agents/agents.module'; import { ReportsModule } from './modules/reports/reports.module'; import { FraudModule } from './modules/fraud/fraud.module'; import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; +import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; @Module({ imports: [ @@ -69,6 +70,7 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; ReportsModule, FraudModule, SeatClassesModule, + FareEngineModule, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 4b8b6885c..4fe3d1473 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -214,6 +214,7 @@ Payment providers send notifications to: .addTag("Auth", "Registration and login") .addTag("Booking", "Booking lifecycle") .addTag("Dashboard", "Home dashboard aggregate") + .addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency") .addTag("Fleet", "Train services and coaches") .addTag("Fraud Detection", "Fraud detection and monitoring") .addTag("Live Tracking", "Real-time trip status and crowd signals") diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 3fcfce179..8cf931bad 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { Currency } from '@prisma/client'; @@ -47,9 +47,8 @@ export class CurrencyService { async syncExchangeRates(): Promise { this.logger.log('Syncing exchange rates from external provider'); - - // In production, fetch from external API - // For now, using static rates + + const today = this.todayUtc(); const rates = [ { from: 'ETB', to: 'ETB', rate: 1.0 }, { from: 'ETB', to: 'DJF', rate: 3.25 }, @@ -59,25 +58,47 @@ export class CurrencyService { ]; for (const { from, to, rate } of rates) { - await this.prisma.currencyExchangeRate.upsert({ - where: { - fromCurrency_toCurrency_effectiveDate: { - fromCurrency: from as Currency, - toCurrency: to as Currency, - effectiveDate: new Date(), - }, - }, - update: { rate }, - create: { - fromCurrency: from as Currency, - toCurrency: to as Currency, - rate, - effectiveDate: new Date(), - source: 'EXTERNAL_API', - }, - }); + await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API'); } this.logger.log('Exchange rates synced successfully'); } + + async listRates() { + return this.prisma.currencyExchangeRate.findMany({ + orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }], + }); + } + + async upsertRate( + fromCurrency: Currency, + toCurrency: Currency, + rate: number, + effectiveDate?: Date, + source = 'MANUAL', + ) { + const date = effectiveDate ?? this.todayUtc(); + return this.prisma.currencyExchangeRate.upsert({ + where: { fromCurrency_toCurrency_effectiveDate: { fromCurrency, toCurrency, effectiveDate: date } }, + update: { rate, source }, + create: { fromCurrency, toCurrency, rate, effectiveDate: date, source }, + }); + } + + async updateRateById(id: string, rate: number, source = 'MANUAL') { + const existing = await this.prisma.currencyExchangeRate.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Exchange rate not found'); + return this.prisma.currencyExchangeRate.update({ where: { id }, data: { rate, source } }); + } + + async deleteRate(id: string) { + return this.prisma.currencyExchangeRate.delete({ where: { id } }); + } + + /** Returns midnight UTC for today — used as the date-only key for upserts. */ + private todayUtc(): Date { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + return d; + } } diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts new file mode 100644 index 000000000..1d35f6b61 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -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(); + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts new file mode 100644 index 000000000..59e863cea --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts new file mode 100644 index 000000000..25718cc8f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts @@ -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, + ); + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts new file mode 100644 index 000000000..536ddaa3b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -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 = { + 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; +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts new file mode 100644 index 000000000..fefdfb9a9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts new file mode 100644 index 000000000..b0411718f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -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.', + ); + } +} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 07d0362af..1f122cd41 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -89,12 +89,45 @@ Origin and destination are derived from the first and last route stop — no nee // ── Fares ────────────────────────────────────────────────────────────────── @Get(':scheduleId/fares') - @ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' }) + @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' }) - @ApiResponse({ status: 200, description: 'Fare rule or default fare' }) + @ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' }) + @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' }) + @ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' }) + @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) + @ApiResponse({ status: 404, description: 'Schedule or seat class not found' }) + getFare( + @Param('scheduleId') scheduleId: string, + @Query('seatClassId') seatClassId: string, + @Query('nationality') nationality?: string, + ) { + return this.service.getFareFromEngine(scheduleId, seatClassId, nationality); + } + + @Get(':scheduleId/fares/all') + @ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' }) + @ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' }) + @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) @ApiResponse({ status: 404, description: 'Schedule not found' }) - getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) { - return this.service.getFare(scheduleId, cls ?? 'Economy Regular'); + getAllFares( + @Param('scheduleId') scheduleId: string, + @Query('nationality') nationality?: string, + ) { + return this.service.getAllFaresFromEngine(scheduleId, nationality); + } + + @Post(':id/fares/sync') + @ApiOperation({ + summary: 'Sync fares from fare engine', + description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.', + }) + @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) + @ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' }) + @ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' }) + @ApiResponse({ status: 404, description: 'Schedule not found' }) + syncFares(@Param('id') id: string) { + return this.service.syncFaresFromEngine(id); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 9df073e74..5eca9f1be 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -3,8 +3,10 @@ import { SchedulesController } from './schedules.controller'; import { SchedulesService } from './schedules.service'; import { RoutesController } from './routes.controller'; import { RoutesService } from './routes.service'; +import { FareEngineModule } from '../fare-engine/fare-engine.module'; @Module({ + imports: [FareEngineModule], controllers: [RoutesController, SchedulesController], providers: [RoutesService, SchedulesService], exports: [RoutesService, SchedulesService], diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 17f2b554b..eb10d6e0b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; +import { FareEngineService } from '../fare-engine/fare-engine.service'; import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto'; @Injectable() @@ -8,6 +9,7 @@ export class SchedulesService { constructor( private prisma: PrismaService, private routesService: RoutesService, + private fareEngine: FareEngineService, ) {} // ── Schedule CRUD ────────────────────────────────────────────────────────── @@ -149,27 +151,51 @@ export class SchedulesService { }); } - async getFare(scheduleId: string, seatClassName: string) { - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: scheduleId }, - include: { originStation: true, destinationStation: true }, - }); - if (!schedule) throw new NotFoundException('Schedule not found'); + getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) { + return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality); + } - const route = `${schedule.originStation.code}-${schedule.destinationStation.code}`; - const seatClass = await this.prisma.seatClass.findFirst({ where: { name: seatClassName } }); + getAllFaresFromEngine(scheduleId: string, nationality?: string) { + return this.fareEngine.calculateAllForSchedule(scheduleId, nationality); + } + + /** + * Recalculate fares for all active seat classes on a schedule using the fare engine + * and upsert them as FareRule records scoped to this schedule. + */ + async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> { + const results = await this.fareEngine.calculateAllForSchedule(scheduleId); + const errors: string[] = []; + let synced = 0; const now = new Date(); - const rule = await this.prisma.fareRule.findFirst({ - where: { - seatClassId: seatClass?.id, - validFrom: { lte: now }, - OR: [{ tripId: scheduleId }, { route }, { tripId: null, route: null }], - AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: now } }] }], - }, - orderBy: { validFrom: 'desc' }, - }); + for (const fare of results as any[]) { + try { + const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } }); + if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; } - return rule ?? { baseFareMinor: 45000, currency: 'ETB', seatClassName }; + // Expire any existing active rule for this schedule + seat class + await this.prisma.fareRule.updateMany({ + where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null }, + data: { validUntil: now }, + }); + + await this.prisma.fareRule.create({ + data: { + tripId: scheduleId, + seatClassId: seatClass.id, + baseFareMinor: fare.totalMinor, + currency: 'ETB', + validFrom: now, + validUntil: null, + }, + }); + synced++; + } catch (err) { + errors.push(`${fare.seatClassName}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return { synced, errors }; } }