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