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

@@ -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 {

View File

@@ -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")

View File

@@ -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<void> {
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;
}
}

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

View File

@@ -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);
}
}

View File

@@ -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],

View File

@@ -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 };
}
}