mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
Fare engine added
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user