mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
Fare and route-coach, production checklist updates
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop
|
||||
ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RouteCoachTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"routeId" TEXT NOT NULL,
|
||||
"coachId" TEXT NOT NULL,
|
||||
"positionNumber" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT,
|
||||
ADD COLUMN "nationalityType" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition");
|
||||
@@ -88,7 +88,9 @@ model SeatClass {
|
||||
coachTypeId String
|
||||
name String
|
||||
description String?
|
||||
baseFareMinor Int @default(0) // per-km rate
|
||||
nationalityType String? // 'LOCAL' | 'INTERNATIONAL'
|
||||
bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat
|
||||
baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000)
|
||||
premiumMinor Int @default(0) // flat fee per passenger
|
||||
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
||||
isActive Boolean @default(true)
|
||||
@@ -100,6 +102,7 @@ model SeatClass {
|
||||
segmentFares SegmentFareRule[]
|
||||
@@unique([coachTypeId, name])
|
||||
@@index([coachTypeId])
|
||||
@@index([coachTypeId, nationalityType, bedPosition])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -420,9 +423,10 @@ model Coach {
|
||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
routeTemplates RouteCoachTemplate[]
|
||||
@@index([coachTypeId])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
@@ -998,6 +1002,7 @@ model Route {
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
schedules TrainSchedule[]
|
||||
coachTemplates RouteCoachTemplate[]
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1015,6 +1020,20 @@ model RouteStop {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model RouteCoachTemplate {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
coachId String
|
||||
positionNumber Int
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
|
||||
@@unique([routeId, positionNumber])
|
||||
@@index([routeId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model RouteFareRule {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
|
||||
@@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() {
|
||||
});
|
||||
}
|
||||
|
||||
// Tariff rates: baseFareMinor = tariff_decimal × 100000
|
||||
// Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate
|
||||
// LOCAL = Ethiopian or Djiboutian nationals
|
||||
// INTERNATIONAL = all other nationalities
|
||||
const seatClasses = [
|
||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
|
||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
|
||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
|
||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
|
||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
|
||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
|
||||
// LOCAL rates
|
||||
{ name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
// INTERNATIONAL rates
|
||||
{ name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
];
|
||||
|
||||
for (const sc of seatClasses) {
|
||||
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
|
||||
await prisma.seatClass.upsert({
|
||||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||||
update: {},
|
||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
|
||||
update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
|
||||
create: {
|
||||
coachTypeId: ct!.id,
|
||||
name: sc.name,
|
||||
nationalityType: sc.nationalityType,
|
||||
bedPosition: sc.bedPosition,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
premiumMinor: sc.premiumMinor,
|
||||
insuranceFeeMinor: sc.insuranceFeeMinor,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
||||
@@ -238,6 +258,58 @@ async function seedRoute() {
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
||||
|
||||
// Full cross-border route: Sebeta → Nagad (all 15 stations)
|
||||
const fullRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-201' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-201',
|
||||
name: 'Sebeta - Nagad (Full Cross-Border)',
|
||||
description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Cumulative distances from Sebeta (km) for all 15 stations
|
||||
const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
|
||||
const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0];
|
||||
for (let i = 0; i < fullStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } },
|
||||
update: { distanceKm: fullDistancesKm[i] },
|
||||
create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
|
||||
// Full cross-border return route: Nagad → Sebeta
|
||||
const fullReturnRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-202' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-202',
|
||||
name: 'Nagad - Sebeta (Full Cross-Border Return)',
|
||||
description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||
const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0];
|
||||
for (let i = 0; i < fullReturnStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } },
|
||||
update: { distanceKm: fullReturnDistancesKm[i] },
|
||||
create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`);
|
||||
}
|
||||
|
||||
async function seedCoaches() {
|
||||
@@ -431,34 +503,61 @@ async function seedTrips() {
|
||||
async function seedFareRules() {
|
||||
console.log('\n💰 Seeding fare rules...');
|
||||
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
|
||||
const fareRules = [];
|
||||
for (const sc of seatClasses) {
|
||||
fareRules.push({
|
||||
routeId: route!.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'ADULT' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
fareRules.push({
|
||||
routeId: route!.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
||||
discountPercent: 10,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
// Delete existing FareRule rows so re-seed is idempotent
|
||||
await prisma.fareRule.deleteMany({});
|
||||
|
||||
const fareRules: any[] = [];
|
||||
for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
|
||||
for (const sc of seatClasses) {
|
||||
fareRules.push({
|
||||
route: route.code,
|
||||
seatClassId: sc.id,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
||||
fareRules.map(fr => prisma.fareRule.create({ data: fr }))
|
||||
);
|
||||
console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`);
|
||||
console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`);
|
||||
|
||||
const allRoutes = await prisma.route.findMany({
|
||||
where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } },
|
||||
});
|
||||
const routeFareRules: any[] = [];
|
||||
for (const r of allRoutes) {
|
||||
for (const sc of seatClasses) {
|
||||
routeFareRules.push({
|
||||
routeId: r.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'ADULT' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
// CHILD: same per-km rate as ADULT — age-based free/paid logic is handled
|
||||
// at booking time (first child free, subsequent children full fare).
|
||||
routeFareRules.push({
|
||||
routeId: r.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {}))
|
||||
);
|
||||
console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`);
|
||||
}
|
||||
|
||||
async function seedCurrency() {
|
||||
@@ -758,7 +857,23 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
|
||||
async function main() {
|
||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['System Users', seedSystemUsers],
|
||||
['Stations', seedStations],
|
||||
['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||
['Route', seedRoute],
|
||||
['Coaches', seedCoaches],
|
||||
['Trips', seedTrips],
|
||||
['Fare Rules', seedFareRules],
|
||||
['Currency', seedCurrency],
|
||||
['Payment Methods', seedPaymentMethods],
|
||||
['Segment Fares', seedSegmentFares],
|
||||
['Notification Templates', seedNotificationTemplates],
|
||||
['Menu & Food', seedMenuAndFood],
|
||||
['Promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['Fraud Rules', seedFraudRules],
|
||||
['Kulubbi Package', seedKulubbiPackage],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
|
||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||
@@ -66,7 +61,6 @@ import { PackagesModule } from './modules/packages/packages.module';
|
||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
|
||||
ExcessBaggageModule,
|
||||
HealthModule,
|
||||
TasksModule,
|
||||
ConfigurableFareModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||
@@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AppModule.name);
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||
@@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
try {
|
||||
await this.seeder.run();
|
||||
} catch (err) {
|
||||
console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await this.edrPassengerOrgSeeder.run();
|
||||
} catch (err) {
|
||||
console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await this.passengerStaffUsersSeeder.run();
|
||||
} catch (err) {
|
||||
console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service';
|
||||
|
||||
// Route-prefix → throttler tier mapping.
|
||||
// Evaluated in order; first match wins.
|
||||
const ROUTE_TIERS: Array<{ prefix: string; tier: 'auth' | 'strict' | 'default' }> = [
|
||||
{ prefix: '/auth', tier: 'auth' },
|
||||
{ prefix: '/fayda/verification',tier: 'auth' },
|
||||
{ prefix: '/bookings', tier: 'strict' },
|
||||
{ prefix: '/passengers', tier: 'strict' },
|
||||
{ prefix: '/payments', tier: 'strict' },
|
||||
{ prefix: '/wallet', tier: 'strict' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class DynamicThrottlerGuard extends ThrottlerGuard {
|
||||
constructor(
|
||||
@@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
|
||||
]);
|
||||
|
||||
this.throttlers = [
|
||||
{ name: 'default', ttl: defaultTtl, limit: defaultLimit },
|
||||
];
|
||||
const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? '';
|
||||
const matched = ROUTE_TIERS.find(({ prefix }) => url.startsWith(prefix));
|
||||
const tier = matched?.tier ?? 'default';
|
||||
|
||||
if (tier === 'auth') {
|
||||
this.throttlers = [{ name: 'auth', ttl: authTtl, limit: authLimit }];
|
||||
} else if (tier === 'strict') {
|
||||
this.throttlers = [{ name: 'strict', ttl: strictTtl, limit: strictLimit }];
|
||||
} else {
|
||||
this.throttlers = [{ name: 'default', ttl: defaultTtl, limit: defaultLimit }];
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// In production set connection_limit and pool_timeout in DATABASE_URL:
|
||||
// ?connection_limit=10&pool_timeout=20&sslmode=require
|
||||
log:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? [{ emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }]
|
||||
: [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(this as any).$on('query', (e: { query: string; duration: number }) => {
|
||||
if (e.duration > 500) {
|
||||
this.logger.warn(`Slow query (${e.duration}ms): ${e.query}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleInit() { await this.$connect(); }
|
||||
async onModuleDestroy() { await this.$disconnect(); }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT ?? '4000', 10),
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
|
||||
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
|
||||
}));
|
||||
export default registerAs('app', () => {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && !process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is required in production');
|
||||
}
|
||||
if (isProd && !process.env.JWT_ACCESS_TOKEN_SECRET) {
|
||||
throw new Error('JWT_ACCESS_TOKEN_SECRET environment variable is required in production');
|
||||
}
|
||||
if (isProd && !process.env.JWT_REFRESH_TOKEN_SECRET) {
|
||||
throw new Error('JWT_REFRESH_TOKEN_SECRET environment variable is required in production');
|
||||
}
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT ?? '4000', 10),
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
|
||||
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
|
||||
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
|
||||
import "dotenv/config";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import helmet from "helmet";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
|
||||
@@ -14,21 +15,32 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi
|
||||
// Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations
|
||||
process.env.TZ = 'Africa/Addis_Ababa';
|
||||
|
||||
// Safety guard: prevent insecure TLS from being enabled in production
|
||||
if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === 'true') {
|
||||
throw new Error('WAAFI_INSECURE_TLS=true is not allowed in production');
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
process.env.PORTAL_URL ?? "http://localhost:5174",
|
||||
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
|
||||
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-Request-ID'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
@@ -38,6 +50,7 @@ async function bootstrap() {
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR Passenger API")
|
||||
.setDescription(
|
||||
@@ -130,7 +143,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
||||
- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
|
||||
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
|
||||
- Complete audit trail per leg for compliance and reporting
|
||||
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
|
||||
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
|
||||
|
||||
### Booking Type Matrix
|
||||
|
||||
@@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
|
||||
|
||||
### Step 3: Passenger Information & Verification
|
||||
**For Ethiopian Passengers:**
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
|
||||
**For International Passengers:**
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
|
||||
### Step 4: View Seat Map
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
|
||||
|
||||
### Step 5: Hold Seats
|
||||
\`POST /seats/hold\` to reserve seats for 15 minutes.
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
|
||||
### Step 6: Create Booking
|
||||
Choose the right endpoint and bookingType:
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
|
||||
### Step 7: Process Payment
|
||||
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
|
||||
@@ -406,10 +419,12 @@ Payment providers send notifications to:
|
||||
operationsSorter: "alpha",
|
||||
},
|
||||
});
|
||||
} // end if (NODE_ENV !== 'production')
|
||||
|
||||
const port = process.env.PORT ?? 4000;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 EDR Passenger API running on port ${port}`);
|
||||
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||
const logger = new Logger('Bootstrap');
|
||||
logger.log(`EDR Passenger API running on port ${port}`);
|
||||
logger.log(`Swagger: http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CurrenciesController } from './currencies.controller';
|
||||
import { CurrenciesService } from './currencies.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [HttpModule, PrismaModule, CurrencyModule],
|
||||
controllers: [CurrenciesController],
|
||||
providers: [CurrenciesService],
|
||||
exports: [CurrenciesService],
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CurrenciesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async getAllCurrencies() {
|
||||
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||
@@ -108,7 +112,12 @@ export class CurrenciesService {
|
||||
}
|
||||
|
||||
async syncExchangeRates() {
|
||||
return { message: 'Exchange rates synced successfully', synced: 0 };
|
||||
await this.currencyService.syncExchangeRates();
|
||||
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
return { message: 'Exchange rates synced successfully', synced: rates.length };
|
||||
}
|
||||
|
||||
private getCurrencyName(code: string): string {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CurrencyService } from './currency.service';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, HttpModule],
|
||||
providers: [CurrencyService],
|
||||
exports: [CurrencyService],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
@@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
|
||||
export class CurrencyService {
|
||||
private readonly logger = new Logger(CurrencyService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async convertEtbMinorToChargeMajor(
|
||||
amountMinorEtb: number,
|
||||
@@ -81,14 +88,11 @@ export class CurrencyService {
|
||||
fromCurrency: Currency,
|
||||
toCurrency: Currency,
|
||||
): Promise<number> {
|
||||
if (fromCurrency === toCurrency) return 1;
|
||||
|
||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||
where: {
|
||||
fromCurrency,
|
||||
toCurrency,
|
||||
},
|
||||
orderBy: {
|
||||
effectiveDate: 'desc',
|
||||
},
|
||||
where: { fromCurrency, toCurrency },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
@@ -98,26 +102,61 @@ export class CurrencyService {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
|
||||
if (ageMs > 2 * 24 * 60 * 60 * 1000) {
|
||||
this.logger.warn(
|
||||
`Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return Number(exchangeRate.rate);
|
||||
}
|
||||
|
||||
async syncExchangeRates(): Promise<void> {
|
||||
this.logger.log('Syncing exchange rates from external provider');
|
||||
this.logger.log('Syncing exchange rates from central bank API');
|
||||
|
||||
const today = this.todayUtc();
|
||||
const rates = [
|
||||
{ from: 'ETB', to: 'ETB', rate: 1.0 },
|
||||
{ from: 'ETB', to: 'DJF', rate: 3.25 },
|
||||
{ from: 'ETB', to: 'USD', rate: 0.018 },
|
||||
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
|
||||
{ from: 'USD', to: 'ETB', rate: 55.56 },
|
||||
// Fallback rates used when the API is unreachable
|
||||
const fallbackRates = [
|
||||
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
|
||||
{ from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
|
||||
{ from: Currency.ETB, to: Currency.USD, rate: 0.018 },
|
||||
{ from: Currency.DJF, to: Currency.ETB, rate: 0.3077 },
|
||||
{ from: Currency.USD, to: Currency.ETB, rate: 55.56 },
|
||||
];
|
||||
|
||||
for (const { from, to, rate } of rates) {
|
||||
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
|
||||
const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
|
||||
if (apiUrl) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<Record<string, number>>(apiUrl, { timeout: 5000 }),
|
||||
);
|
||||
// Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... }
|
||||
const data = response.data;
|
||||
const apiRates = [
|
||||
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
|
||||
{ from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate },
|
||||
{ from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate },
|
||||
{ from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate },
|
||||
{ from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate },
|
||||
];
|
||||
for (const { from, to, rate } of apiRates) {
|
||||
await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API');
|
||||
}
|
||||
this.logger.log('Exchange rates synced from central bank API');
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Central bank API unreachable (${(err as Error).message}), falling back to configured rates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Exchange rates synced successfully');
|
||||
// Fallback: persist the static rates so the DB always has a current row
|
||||
for (const { from, to, rate } of fallbackRates) {
|
||||
await this.upsertRate(from, to, rate, today, 'FALLBACK');
|
||||
}
|
||||
this.logger.log('Exchange rates synced using fallback values');
|
||||
}
|
||||
|
||||
async listRates() {
|
||||
|
||||
@@ -4,8 +4,6 @@ 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(
|
||||
@@ -32,6 +30,22 @@ export class FareEngineService {
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
// Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
|
||||
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
|
||||
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
|
||||
? 'LOCAL' : 'INTERNATIONAL';
|
||||
|
||||
// Find the nationality-specific seat class for the same coach type and bed position.
|
||||
// Falls back to the requested seatClass if no nationality-specific one exists.
|
||||
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
|
||||
where: {
|
||||
coachTypeId: seatClass.coachTypeId,
|
||||
nationalityType,
|
||||
bedPosition: seatClass.bedPosition ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
}) ?? seatClass;
|
||||
|
||||
// Calculate distance: distanceKm represents cumulative distance from route origin
|
||||
// For a segment, distance = destination.distanceKm - origin.distanceKm
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
@@ -102,9 +116,10 @@ export class FareEngineService {
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SCHEDULE_FARE_RULE';
|
||||
} else {
|
||||
// Default: distance-based using live SeatClass rate
|
||||
ratePerKmMinor = seatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm);
|
||||
// Default: distance-based using tariff formula: km × rate × 1.02
|
||||
// baseFareMinor stores the per-km rate (tariff decimal × 100000)
|
||||
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
|
||||
fareSource = 'SEAT_CLASS_BASE_FARE';
|
||||
}
|
||||
|
||||
@@ -138,8 +153,7 @@ export class FareEngineService {
|
||||
}
|
||||
|
||||
const afterDiscountMinor = subtotalMinor - discountMinor;
|
||||
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
|
||||
const totalEtbMinor = afterDiscountMinor + taxMinor;
|
||||
const totalEtbMinor = afterDiscountMinor;
|
||||
|
||||
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
@@ -147,8 +161,9 @@ export class FareEngineService {
|
||||
|
||||
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`,
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`,
|
||||
`Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
|
||||
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
|
||||
`Premium/pax: ${premiumPerPassenger} ETB minor`,
|
||||
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
|
||||
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
|
||||
@@ -160,10 +175,8 @@ export class FareEngineService {
|
||||
``,
|
||||
`Subtotal: ${subtotalMinor} ETB minor`,
|
||||
`Discount: ${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`,
|
||||
`Fare source: ${fareSource}`,
|
||||
@@ -174,8 +187,8 @@ export class FareEngineService {
|
||||
routeCode: route.code,
|
||||
originName: originStation?.name ?? dto.originStationId,
|
||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||
seatClassId: seatClass.id,
|
||||
seatClassName: seatClass.name,
|
||||
seatClassId: nationalitySeatClass.id,
|
||||
seatClassName: nationalitySeatClass.name,
|
||||
totalDistanceKm,
|
||||
ratePerKmMinor,
|
||||
baseFarePerPassengerMinor,
|
||||
@@ -188,7 +201,6 @@ export class FareEngineService {
|
||||
paidChildrenCount,
|
||||
subtotalMinor,
|
||||
discountMinor,
|
||||
taxMinor,
|
||||
totalMinor: totalEtbMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency,
|
||||
@@ -313,16 +325,13 @@ export class FareEngineService {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
return fareRules.map(rule => {
|
||||
const seatClassId = rule.seatClassId;
|
||||
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
|
||||
const totalMinor = rule.baseFareMinor + taxMinor;
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
taxMinor,
|
||||
totalMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
|
||||
|
||||
// ── Route Coach Template ───────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/coaches')
|
||||
@ApiOperation({ summary: 'Get the default coach lineup for this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
|
||||
|
||||
@Put(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Set the default coach lineup for this route',
|
||||
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Updated coach template' })
|
||||
@ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' })
|
||||
@ApiResponse({ status: 404, description: 'Route or coach not found' })
|
||||
setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) {
|
||||
return this.service.setRouteCoachTemplate(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Template cleared' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); }
|
||||
}
|
||||
|
||||
@@ -44,3 +44,14 @@ export class UpdateRouteDto {
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
export class RouteCoachTemplateItemDto {
|
||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number;
|
||||
}
|
||||
|
||||
export class SetRouteCoachTemplateDto {
|
||||
@ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto)
|
||||
coaches: RouteCoachTemplateItemDto[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
@Injectable()
|
||||
@@ -202,6 +202,44 @@ export class RoutesService {
|
||||
});
|
||||
}
|
||||
|
||||
async getRouteCoachTemplate(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
return this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId },
|
||||
include: { coach: { include: { coachType: true } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const coachIds = dto.coaches.map(c => c.coachId);
|
||||
const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||
if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||
const inactive = coaches.find(c => c.status !== 'ACTIVE');
|
||||
if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`);
|
||||
|
||||
const positions = dto.coaches.map(c => c.positionNumber);
|
||||
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
|
||||
|
||||
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
|
||||
await this.prisma.routeCoachTemplate.createMany({
|
||||
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
|
||||
});
|
||||
|
||||
return this.getRouteCoachTemplate(routeId);
|
||||
}
|
||||
|
||||
async removeRouteCoachTemplate(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
|
||||
return { deleted: true, routeId };
|
||||
}
|
||||
|
||||
// ── Used by SchedulesService ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto {
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export class SchedulesService {
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// createSchedule already auto-applies the route coach template;
|
||||
// only override if explicit coachIds are provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
@@ -177,6 +179,18 @@ export class SchedulesService {
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
// Auto-apply route coach template if one is defined
|
||||
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId: dto.routeId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
if (coachTemplates.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
|
||||
);
|
||||
}
|
||||
|
||||
return this.getSchedule(schedule.id);
|
||||
}
|
||||
|
||||
@@ -583,10 +597,10 @@ export class SchedulesService {
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
const data = coaches.map((c, idx) => ({
|
||||
const data = coaches.map((c) => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: idx + 1,
|
||||
positionNumber: c.positionNumber,
|
||||
isOperational: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ export class SearchService {
|
||||
insuranceFeeMinor: fare.insurancePerPassenger,
|
||||
totalBaseFareMinor: fare.subtotalMinor,
|
||||
discountMinor: fare.discountMinor,
|
||||
taxesFeesMinor: fare.taxMinor,
|
||||
taxesFeesMinor: 0,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
|
||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||
const MAX_PAYMENT_HOURS = 2;
|
||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||
const CUTOFF_MINUTES = 30;
|
||||
|
||||
// Retention windows
|
||||
const OTP_RETENTION_HOURS = 1;
|
||||
const FAYDA_SESSION_RETENTION_HOURS = 1;
|
||||
const AUDIT_LOG_RETENTION_DAYS = 365;
|
||||
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
|
||||
const GATE_LOG_RETENTION_DAYS = 180;
|
||||
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
*/
|
||||
@@ -32,6 +40,7 @@ export class TasksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -243,4 +252,47 @@ export class TasksService {
|
||||
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 01:00 EAT: fetch mid-market rates from central bank API.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' })
|
||||
async syncExchangeRates() {
|
||||
try {
|
||||
await this.currencyService.syncExchangeRates();
|
||||
} catch (err) {
|
||||
this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('0 2 * * *')
|
||||
async purgeExpiredData() {
|
||||
const now = new Date();
|
||||
|
||||
const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000);
|
||||
const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000);
|
||||
const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([
|
||||
this.prisma.otpCode.deleteMany({
|
||||
where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] },
|
||||
}),
|
||||
this.prisma.faydaVerificationSession.deleteMany({
|
||||
where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] },
|
||||
}),
|
||||
this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }),
|
||||
this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }),
|
||||
this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }),
|
||||
]);
|
||||
|
||||
this.logger.log(
|
||||
`Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` +
|
||||
`${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user