Merge branch 'dev' into feat/improve-buid-pipeline

This commit is contained in:
Sennay
2026-05-28 13:50:51 +03:00
committed by GitHub
41 changed files with 3161 additions and 404 deletions

View File

@@ -2,3 +2,106 @@
PORT=4000
DATABASE_URL=postgresql://user:password@host:5432/edr_passenger
JWT_SECRET=change-me-in-production
# Database (Prisma)
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger
# CORS
FRONTEND_URL=http://localhost:3000
PORTAL_URL=http://localhost:3001
# JWT
JWT_SECRET=edr-platform-secret-change-in-production
JWT_EXPIRES_IN=7d
# SendGrid
SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
# SMS Configuration
SMS_PROVIDER=twilio
SMS_API_KEY=
# Twilio (if SMS_PROVIDER=twilio)
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
# Africa's Talking (if SMS_PROVIDER=africastalking)
AFRICASTALKING_USERNAME=
AFRICASTALKING_FROM=
# Telebirr
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
TELEBIRR_FABRIC_APP_ID=
TELEBIRR_APP_SECRET=
TELEBIRR_MERCHANT_APP_ID=
TELEBIRR_MERCHANT_CODE=
TELEBIRR_NOTIFY_URL=
TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# CBE Birr
CBE_BASE_URL=
CBE_MERCHANT_ID=
CBE_SECRET_KEY=
CBE_NOTIFY_URL=
CBE_RETURN_URL=
# eBirr
EBIRR_BASE_URL=
EBIRR_MERCHANT_CODE=
EBIRR_SECRET_KEY=
EBIRR_NOTIFY_URL=
EBIRR_RETURN_URL=
# Card Gateway (Stripe-like)
CARD_BASE_URL=
CARD_API_KEY=
CARD_WEBHOOK_SECRET=
CARD_WEBHOOK_URL=
CARD_RETURN_URL=
# Waafi (Djibouti Mobile Money)
WAAFI_BASE_URL=https://api.waafipay.net
WAAFI_MERCHANT_UID=
WAAFI_API_USER_ID=
WAAFI_API_KEY=
WAAFI_NOTIFY_URL=
WAAFI_RETURN_URL=
# Payment Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
# Session Configuration
SESSION_INACTIVITY_MINUTES=30
# i18n Configuration
DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,am,fr,om
# Corporate IAM Configuration (for back-office authentication)
IAM_ENABLED=false
IAM_API_URL=https://iam.tria-plc.com/api
IAM_API_KEY=
# --- VeriFayda 2.0 (eSignet) OIDC integration ---
FAYDA_ENABLED=true
FAYDA_CLIENT_ID=
FAYDA_AUTHORIZATION_ENDPOINT=
FAYDA_TOKEN_ENDPOINT=
FAYDA_USERINFO_ENDPOINT=
# Base64 of the RSA private JWK (JSON). Secret — never commit a real value.
FAYDA_PRIVATE_KEY_BASE64=
FAYDA_REDIRECT_URI=
# Optional (defaults shown)
FAYDA_SCOPE=openid profile email
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
GITHUB_PACKAGE_TOKEN=

View File

@@ -37,6 +37,7 @@
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"jose": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.3",

View File

@@ -0,0 +1,55 @@
/*
Warnings:
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
ADD COLUMN "faydaVerifiedName" TEXT;
-- AlterTable
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "passenger"."FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
-- AddForeignKey
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';

View File

@@ -224,6 +224,11 @@ model User {
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
faydaVerified Boolean @default(false)
faydaVerifiedAt DateTime?
faydaSub String? @unique
passenger Passenger?
agent Agent?
sessions Session[]
@@ -232,6 +237,8 @@ model User {
auditLogs AuditLog[]
fraudAlerts FraudAlert[]
faydaVerificationSessions FaydaVerificationSession[]
@@schema("passenger")
}
@@ -515,6 +522,9 @@ model BookingSeat {
passportCountry String?
verifaydaVerified Boolean @default(false)
verifaydaData Json?
faydaVerifiedAt DateTime?
faydaSub String?
faydaVerifiedName String?
seatLabelSnapshot String?
fareMinor Int?
displayCurrency Currency?
@@ -1256,3 +1266,32 @@ model SavedPassengerProfile {
@@schema("passenger")
}
model FaydaVerificationSession {
id String @id @default(uuid())
state String @unique
codeVerifier String
purpose String @default("PURCHASE")
platform String @default("WEB") // WEB | MOBILE — recorded for audit
saveToAccount Boolean @default(false)
status String @default("PENDING")
errorCode String?
errorDescription String?
authCode String?
createdAt DateTime @default(now())
expiresAt DateTime
completedAt DateTime?
userId String?
bookingId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([bookingId])
@@index([state])
@@index([expiresAt])
@@schema("passenger")
}

View File

@@ -13,6 +13,7 @@ import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config';
import cardConfig from './config/card.config';
import waafiConfig from './config/waafi.config';
import faydaConfig from './config/fayda.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
@@ -35,12 +36,23 @@ 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';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, dbConfig, telebirrConfig, cbeConfig, ebirrConfig, cardConfig, waafiConfig],
load: [
appConfig,
dbConfig,
telebirrConfig,
cbeConfig,
ebirrConfig,
cardConfig,
waafiConfig,
faydaConfig,
],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -69,6 +81,8 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
ReportsModule,
FraudModule,
SeatClassesModule,
FareEngineModule,
VerifaydaModule,
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,118 @@
import { registerAs } from '@nestjs/config';
export interface FaydaJwk {
kty: 'RSA';
use?: string;
kid?: string;
alg?: string;
n: string;
e: string;
d: string;
p?: string;
q?: string;
dp?: string;
dq?: string;
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export interface FaydaConfig {
enabled: boolean;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
redirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
claimsLocales: string;
sessionTtlMinutes: number;
}
const REQUIRED_VARS = [
'FAYDA_CLIENT_ID',
'FAYDA_AUTHORIZATION_ENDPOINT',
'FAYDA_TOKEN_ENDPOINT',
'FAYDA_USERINFO_ENDPOINT',
'FAYDA_PRIVATE_KEY_BASE64',
] as const;
function decodePrivateJwk(base64: string): FaydaJwk {
let jwk: unknown;
try {
const json = Buffer.from(base64, 'base64').toString('utf8');
jwk = JSON.parse(json);
} catch (err) {
throw new Error(
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
);
}
if (!jwk || typeof jwk !== 'object') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
}
const candidate = jwk as Partial<FaydaJwk>;
if (candidate.kty !== 'RSA') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
}
if (!candidate.n || !candidate.e || !candidate.d) {
throw new Error(
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
);
}
return candidate as FaydaJwk;
}
export default registerAs('fayda', (): FaydaConfig => {
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email';
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
if (!enabled) {
return {
enabled: false,
clientId: process.env.FAYDA_CLIENT_ID ?? '',
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
};
}
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
if (!redirectUri) {
throw new Error(
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
);
}
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
}
return {
enabled: true,
clientId: process.env.FAYDA_CLIENT_ID!,
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: sessionTtl,
};
});

View File

@@ -214,6 +214,7 @@ Payment providers send notifications to:
.addTag("Auth", "Registration and login")
.addTag("Booking", "Booking lifecycle")
.addTag("Dashboard", "Home dashboard aggregate")
.addTag("Fare Engine", "Distance-based fare calculator — km × rate × exchange rate, nationality-aware currency")
.addTag("Fleet", "Train services and coaches")
.addTag("Fraud Detection", "Fraud detection and monitoring")
.addTag("Live Tracking", "Real-time trip status and crowd signals")

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client';
@@ -47,9 +47,8 @@ export class CurrencyService {
async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from external provider');
// In production, fetch from external API
// For now, using static rates
const today = this.todayUtc();
const rates = [
{ from: 'ETB', to: 'ETB', rate: 1.0 },
{ from: 'ETB', to: 'DJF', rate: 3.25 },
@@ -59,25 +58,47 @@ export class CurrencyService {
];
for (const { from, to, rate } of rates) {
await this.prisma.currencyExchangeRate.upsert({
where: {
fromCurrency_toCurrency_effectiveDate: {
fromCurrency: from as Currency,
toCurrency: to as Currency,
effectiveDate: new Date(),
},
},
update: { rate },
create: {
fromCurrency: from as Currency,
toCurrency: to as Currency,
rate,
effectiveDate: new Date(),
source: 'EXTERNAL_API',
},
});
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
}
this.logger.log('Exchange rates synced successfully');
}
async listRates() {
return this.prisma.currencyExchangeRate.findMany({
orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }],
});
}
async upsertRate(
fromCurrency: Currency,
toCurrency: Currency,
rate: number,
effectiveDate?: Date,
source = 'MANUAL',
) {
const date = effectiveDate ?? this.todayUtc();
return this.prisma.currencyExchangeRate.upsert({
where: { fromCurrency_toCurrency_effectiveDate: { fromCurrency, toCurrency, effectiveDate: date } },
update: { rate, source },
create: { fromCurrency, toCurrency, rate, effectiveDate: date, source },
});
}
async updateRateById(id: string, rate: number, source = 'MANUAL') {
const existing = await this.prisma.currencyExchangeRate.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Exchange rate not found');
return this.prisma.currencyExchangeRate.update({ where: { id }, data: { rate, source } });
}
async deleteRate(id: string) {
return this.prisma.currencyExchangeRate.delete({ where: { id } });
}
/** Returns midnight UTC for today — used as the date-only key for upserts. */
private todayUtc(): Date {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
return d;
}
}

View File

@@ -0,0 +1,54 @@
import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse } from '@nestjs/swagger';
import { CurrencyService } from '../currency/currency.service';
import { UpsertExchangeRateDto } from './currency.dto';
import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
class UpdateExchangeRateDto {
@ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
@ApiProperty({ example: 'MANUAL', required: false }) @IsOptional() @IsString() source?: string;
}
@ApiTags('Fare Engine')
@Controller('fare-engine/exchange-rates')
export class CurrencyController {
constructor(private currency: CurrencyService) {}
@Get()
@ApiOperation({ summary: 'List all exchange rates (latest per pair first)' })
list() {
return this.currency.listRates();
}
@Put()
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
upsert(@Body() dto: UpsertExchangeRateDto) {
return this.currency.upsertRate(dto.fromCurrency, dto.toCurrency, dto.rate, undefined, dto.source);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an exchange rate by ID' })
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
@ApiResponse({ status: 200, description: 'Rate updated' })
@ApiResponse({ status: 404, description: 'Rate not found' })
update(@Param('id') id: string, @Body() dto: UpdateExchangeRateDto) {
return this.currency.updateRateById(id, dto.rate, dto.source);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an exchange rate record by ID' })
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
@ApiResponse({ status: 200, description: 'Rate deleted' })
@ApiResponse({ status: 404, description: 'Rate not found' })
remove(@Param('id') id: string) {
return this.currency.deleteRate(id);
}
@Post('sync')
@ApiOperation({ summary: 'Trigger exchange rate sync from external provider' })
sync() {
return this.currency.syncExchangeRates();
}
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsNumber, IsPositive, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
export class UpsertExchangeRateDto {
@ApiProperty({ enum: Currency, example: 'ETB' }) @IsEnum(Currency) fromCurrency: Currency;
@ApiProperty({ enum: Currency, example: 'DJF' }) @IsEnum(Currency) toCurrency: Currency;
@ApiProperty({ example: 3.25 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number;
@ApiPropertyOptional({ example: 'MANUAL', description: 'Source label e.g. MANUAL, EXTERNAL_API' })
@IsOptional() @IsString() source?: string;
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { FareEngineService } from './fare-engine.service';
import { FareCalculateDto, FareBreakdownDto } from './fare-engine.dto';
@ApiTags('Fare Engine')
@Controller('fare-engine')
export class FareEngineController {
constructor(private service: FareEngineService) {}
@Post('calculate')
@ApiOperation({
summary: 'Calculate fare for a journey leg',
description: `Computes fare using the formula:
**Fare = totalKm × ratePerKm × exchangeRate**
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
- \`ratePerKm\`\`SeatClass.basePrice\` (stored in ETB minor units per km)
- \`exchangeRate\` — derived from passenger nationality:
- **Ethiopian** → ETB (rate = 1.0)
- **Djiboutian** → DJF (rate ≈ 3.25)
- **Other / unspecified** → USD (rate ≈ 0.018)
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
5% tax applied after promo discount.
Returns a full breakdown including a human-readable calculation trace.`,
})
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
calculate(@Body() dto: FareCalculateDto) {
return this.service.calculate(dto);
}
@Get('compare')
@ApiOperation({
summary: 'Compare fares across all seat classes for a route leg',
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
})
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
compareClasses(
@Query('routeId') routeId: string,
@Query('originStationId') originStationId: string,
@Query('destinationStationId') destinationStationId: string,
@Query('nationality') nationality?: string,
@Query('adultCount') adultCount?: string,
@Query('childCount') childCount?: string,
) {
return this.service.compareClasses(
routeId,
originStationId,
destinationStationId,
nationality,
adultCount ? parseInt(adultCount) : 1,
childCount ? parseInt(childCount) : 0,
);
}
}

View File

@@ -0,0 +1,72 @@
import { IsString, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
// Nationality → home currency mapping
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
Ethiopian: Currency.ETB,
Djiboutian: Currency.DJF,
};
export function resolveCurrencyFromNationality(nationality?: string): Currency {
if (!nationality) return Currency.ETB;
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD;
}
export class FareCalculateDto {
@ApiProperty({ example: 'route-uuid', description: 'Route UUID — used to look up stop distances' })
@IsString() routeId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the route)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID (must come after origin)' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID — its basePrice is the per-km rate in ETB minor units' })
@IsString() seatClassId: string;
@ApiPropertyOptional({
example: 'Ethiopian',
description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.',
})
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({
example: 2,
description: 'Number of adult passengers (age ≥ 5). Defaults to 1.',
})
@IsOptional() @Type(() => Number) @IsInt() @Min(1) adultCount?: number;
@ApiPropertyOptional({
example: 1,
description: 'Number of child passengers (age < 5). First child travels free.',
})
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
@IsOptional() @IsString() promoCode?: string;
}
export class FareBreakdownDto {
@ApiProperty({ example: 'ADD-DJI' }) routeCode: string;
@ApiProperty({ example: 'Addis Ababa' }) originName: string;
@ApiProperty({ example: 'Djibouti' }) destinationName: string;
@ApiProperty({ example: 'Economy Regular' }) seatClassName: string;
@ApiProperty({ example: 756 }) totalDistanceKm: number;
@ApiProperty({ example: 120 }) ratePerKmMinor: number;
@ApiProperty({ example: 90720 }) baseFarePerPassengerMinor: number;
@ApiProperty({ example: 2 }) adultCount: number;
@ApiProperty({ example: 1 }) childCount: number;
@ApiProperty({ example: 1 }) freeChildrenCount: number;
@ApiProperty({ example: 0 }) paidChildrenCount: number;
@ApiProperty({ example: 181440 }) subtotalMinor: number;
@ApiProperty({ example: 0 }) discountMinor: number;
@ApiProperty({ example: 9072 }) taxMinor: number;
@ApiProperty({ example: 190512 }) totalMinor: number;
@ApiProperty({ example: 'ETB', enum: Currency }) billingCurrency: Currency;
@ApiProperty({ example: 190512 }) totalInBillingCurrency: number;
@ApiProperty({ example: 3.25 }) exchangeRate: number;
@ApiProperty({ description: 'Step-by-step calculation trace for transparency' }) calculation: string;
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FareEngineController } from './fare-engine.controller';
import { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [CurrencyModule],
controllers: [FareEngineController, CurrencyController],
providers: [FareEngineService],
exports: [FareEngineService],
})
export class FareEngineModule {}

View File

@@ -0,0 +1,224 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
import { Currency } from '@prisma/client';
const TAX_RATE = 0.05;
@Injectable()
export class FareEngineService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async calculate(dto: FareCalculateDto) {
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) throw new NotFoundException('Route not found');
const originStop = route.stops.find(s => s.stationId === dto.originStationId);
const destStop = route.stops.find(s => s.stationId === dto.destinationStationId);
if (!originStop) throw new BadRequestException('Origin station not found on this route');
if (!destStop) throw new BadRequestException('Destination station not found on this route');
if (originStop.sequence >= destStop.sequence)
throw new BadRequestException('Origin must come before destination in the route sequence');
const legStops = route.stops.filter(
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
);
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
if (missingDistance.length > 0)
throw new BadRequestException(
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
);
const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
const ratePerKmMinor = seatClass.basePrice;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const paidChildrenCount = Math.max(0, childCount - 1);
const subtotalMinor =
baseFarePerPassengerMinor * adultCount +
baseFarePerPassengerMinor * paidChildrenCount;
let discountMinor = 0;
let promoLabel = 'none';
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff
? Math.round(subtotalMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
promoLabel = `${dto.promoCode} (-${promo.percentOff ?? 0}%)`;
}
}
const afterDiscountMinor = subtotalMinor - discountMinor;
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
const totalEtbMinor = afterDiscountMinor + taxMinor;
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
const [originStation, destStation] = await Promise.all([
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
]);
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
`Subtotal: ${subtotalMinor} ETB minor`,
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
].join('\n');
return {
routeCode: route.code,
originName: originStation?.name ?? dto.originStationId,
destinationName: destStation?.name ?? dto.destinationStationId,
seatClassName: seatClass.name,
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
adultCount,
childCount,
freeChildrenCount,
paidChildrenCount,
subtotalMinor,
discountMinor,
taxMinor,
totalMinor: totalEtbMinor,
billingCurrency,
totalInBillingCurrency,
exchangeRate,
calculation,
};
}
async compareClasses(
routeId: string,
originStationId: string,
destinationStationId: string,
nationality?: string,
adultCount = 1,
childCount = 0,
) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
});
const results = await Promise.all(
seatClasses.map(sc =>
this.calculate({ routeId, originStationId, destinationStationId, seatClassId: sc.id, nationality, adultCount, childCount })
.catch(() => null),
),
);
return results.filter(Boolean);
}
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
return this.calculate({
routeId: schedule.routeId,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId,
nationality,
});
}
/** Calculate fares for all active seat classes on a schedule. */
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
// ── Route-based calculation (fare engine) ────────────────────────────────
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
orderBy: { basePrice: 'asc' },
});
const results = await Promise.all(
seatClasses.map(sc =>
this.calculate({
routeId: schedule.routeId!,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
seatClassId: sc.id,
nationality,
}).catch(() => null),
),
);
return results.filter(Boolean);
}
// ── Fallback: FareRule records scoped to this schedule ───────────────────
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
tripId: scheduleId,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
include: { seatClass: true },
orderBy: { seatClass: { basePrice: 'asc' } },
});
if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => ({
seatClassId: rule.seatClassId,
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
totalMinor: rule.baseFareMinor,
billingCurrency,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
}));
}
throw new BadRequestException(
'Schedule has no associated route and no fare rules. Assign a route or create fare rules for this schedule.',
);
}
}

View File

@@ -89,12 +89,45 @@ Origin and destination are derived from the first and last route stop — no nee
// ── Fares ──────────────────────────────────────────────────────────────────
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' })
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
@ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
@ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
getFare(
@Param('scheduleId') scheduleId: string,
@Query('seatClassId') seatClassId: string,
@Query('nationality') nationality?: string,
) {
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
}
@Get(':scheduleId/fares/all')
@ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
getAllFares(
@Param('scheduleId') scheduleId: string,
@Query('nationality') nationality?: string,
) {
return this.service.getAllFaresFromEngine(scheduleId, nationality);
}
@Post(':id/fares/sync')
@ApiOperation({
summary: 'Sync fares from fare engine',
description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
})
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
@ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
syncFares(@Param('id') id: string) {
return this.service.syncFaresFromEngine(id);
}
}

View File

@@ -3,8 +3,10 @@ import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({
imports: [FareEngineModule],
controllers: [RoutesController, SchedulesController],
providers: [RoutesService, SchedulesService],
exports: [RoutesService, SchedulesService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
@Injectable()
@@ -8,6 +9,7 @@ export class SchedulesService {
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
) {}
// ── Schedule CRUD ──────────────────────────────────────────────────────────
@@ -100,7 +102,63 @@ export class SchedulesService {
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
return schedule;
// Compute effective seat statuses from SeatHold + JourneySegment
// (seat.status DB column is no longer written during booking)
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
return {
...schedule,
coachAssignments: schedule.coachAssignments.map(a => ({
...a,
coach: {
...a.coach,
seats: a.coach.seats.map(s => ({
...s,
status: effectiveStatuses.get(s.id) ?? s.status,
})),
},
})),
};
}
/**
* Computes effective seat status for a schedule by checking active SeatHolds
* and confirmed JourneySegments. The DB seat.status column is not written
* during segment-based booking, so this overlay is required.
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
*/
private async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
const [activeHolds, bookedSegments] = await Promise.all([
this.prisma.seatHold.findMany({
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
select: { seatIds: true },
}),
this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
}),
]);
for (const hold of activeHolds)
for (const seatId of hold.seatIds)
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
for (const seg of bookedSegments)
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
return statusMap;
}
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
@@ -149,27 +207,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 };
}
}

View File

@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
@Module({
imports: [CurrencyModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService]
@Module({
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
})
export class SearchModule {}

View File

@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10;
@@ -11,6 +13,8 @@ export class SearchService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
) {}
async searchTrips(dto: SearchTripsDto) {
@@ -56,7 +60,8 @@ export class SearchService {
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
const free = await this.isSeatFreeForSegment(
// Use segment-aware check — a seat booked A→B is still free for B→D
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
@@ -68,6 +73,11 @@ export class SearchService {
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
// Fetch fares for all seat classes from fare engine in one call
const faresByClass = await this.fareEngine
.calculateAllForSchedule(schedule.id, dto.nationality)
.catch(() => []);
results.push({
scheduleId: schedule.id,
trainNumber: schedule.train.number,
@@ -92,7 +102,6 @@ export class SearchService {
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
),
status: schedule.status,
// Only return stops within the requested leg (origin → destination inclusive)
stops: schedule.stopTimes
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map(st => ({
@@ -104,6 +113,7 @@ export class SearchService {
})),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
});
}
@@ -230,70 +240,6 @@ export class SearchService {
};
}
/**
* Returns true if the seat has no active hold or confirmed booking
* whose segment range overlaps [fromSeq, toSeq).
* Overlap condition: existingFrom < toSeq AND fromSeq < existingTo
*/
private async isSeatFreeForSegment(
scheduleId: string,
seatId: string,
fromSeq: number,
toSeq: number,
): Promise<boolean> {
// Check active holds that include this seat on this schedule
const holds = await this.prisma.seatHold.findMany({
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
for (const hold of holds) {
// Resolve hold segment range from its stored origin/destination via JourneySegment
// For holds we use the stop sequences stored on the hold's origin/destination
// Since SeatHold doesn't store sequences directly, we check JourneySegments
// that reference this seat on this schedule with PENDING_PAYMENT status
const holdSegs = await this.prisma.journeySegment.findMany({
where: { scheduleId, seatId },
include: {
journey: true,
schedule: { include: { stopTimes: true } },
},
});
for (const js of holdSegs) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined) {
if (depSeq < toSeq && fromSeq < arrSeq) return false;
}
}
// If no journey segments yet (hold just created), treat the whole hold as blocking
if (holdSegs.length === 0) return false;
}
// Check confirmed/pending bookings via JourneySegment
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
include: {
schedule: { include: { stopTimes: true } },
},
});
for (const js of bookedSegments) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined) {
if (depSeq < toSeq && fromSeq < arrSeq) return false;
}
}
return true;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
'Economy Regular': 45000,

View File

@@ -26,6 +26,34 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
// ── Hold / Release ────────────────────────────────────────────────────────
@Get('holds')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'List active seat holds with full leg context',
description: `Returns all non-expired holds enriched with:
- **schedule**: train number, departure/arrival, full route origin→destination
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
- **seats**: seat label, coach, seat class, row, col
- **ttlSeconds**: seconds remaining before the hold expires
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
})
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' })
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' })
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' })
getHolds(
@Query('scheduleId') scheduleId?: string,
@Query('passengerId') passengerId?: string,
) { return this.service.getHolds(scheduleId, passengerId); }
@Get('holds/:holdId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get a single hold with full leg context' })
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' })
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' })
@ApiResponse({ status: 404, description: 'Hold not found' })
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
@Post('hold')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({

View File

@@ -1,9 +1,35 @@
import { IsString, IsArray, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsArray, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class PassengerSeatDto {
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
@IsString() passengerId: string;
@ApiProperty({ example: 'seat-uuid', description: 'Seat UUID assigned to this passenger' })
@IsString() seatId: string;
}
export class HoldSeatsDto {
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
@ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[];
@ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID — scopes the hold to a leg so the seat can be reused on non-overlapping legs' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@ApiProperty({
type: [PassengerSeatDto],
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',
example: [
{ passengerId: 'passenger-uuid-1', seatId: 'seat-uuid-1' },
{ passengerId: 'passenger-uuid-2', seatId: 'seat-uuid-2' },
],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => PassengerSeatDto)
passengers: PassengerSeatDto[];
}

View File

@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
@Module({
imports: [SegmentsModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],
})
export class SeatsModule {}

View File

@@ -1,11 +1,15 @@
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
@Injectable()
export class SeatsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
async getSeatMap(scheduleId: string, coachId?: string) {
@@ -14,6 +18,10 @@ export class SeatsService {
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
orderBy: { positionNumber: 'asc' },
});
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
return {
coaches: assignments.map((a) => ({
id: a.coach.id,
@@ -21,34 +29,334 @@ export class SeatsService {
name: `Coach ${a.coach.label}`,
seatClass: a.coach.seatClass.name,
positionNumber: a.positionNumber,
seats: a.coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
seats: a.coach.seats.map((s) => ({
id: s.id,
number: s.label,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
})),
})),
};
}
/**
* Computes the effective seat status for a set of seats on a specific schedule
* by checking active SeatHolds and confirmed JourneySegments.
*
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
*
* This is needed because seat.status is no longer written during booking —
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
*/
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
expiresAt: { gt: new Date() },
seatIds: { hasSome: seatIds },
},
select: { seatIds: true },
});
for (const hold of activeHolds) {
for (const seatId of hold.seatIds) {
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
}
}
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
// (overwrites HELD if the same seat has a confirmed booking)
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
});
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
}
return statusMap;
}
// ── Hold / Release ────────────────────────────────────────────────────────
async holdSeats(dto: HoldSeatsDto) {
// ── Validate request integrity ───────────────────────────────────────────
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
if (new Set(passengerIds).size !== passengerIds.length)
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } });
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },
select: { id: true, status: true, label: true },
});
if (seats.length !== seatIds.length) {
const found = new Set(seats.map(s => s.id));
const missing = seatIds.filter(id => !found.has(id));
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
// ── 2. Resolve requested leg sequences ──────────────────────────────
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) =>
stopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
if (reqFrom === undefined || reqTo === undefined)
throw new BadRequestException('Origin or destination station not found on this schedule');
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── 3. Load active holds for this schedule ───────────────────────────
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
// Parse each hold's leg range and passenger list
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
for (const h of activeHolds) {
try {
if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId);
if (holdFrom !== undefined && holdTo !== undefined) {
parsedHolds.push({
seatIds: h.seatIds,
from: holdFrom,
to: holdTo,
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
});
}
}
} catch { /* ignore malformed */ }
}
// ── 4. Per-passenger validation with overlap check ───────────────────
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
if (!legsOverlap) continue; // non-overlapping leg — no conflict
// Rule A: seat is held on an overlapping leg
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
);
}
// Rule B: passenger already holds a seat on an overlapping leg
if (hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
);
}
}
}
// Store passenger→seat mapping AND leg in createdBy as JSON
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
passengerId: dto.passengers[0].passengerId,
seatIds,
createdBy: JSON.stringify(holdMeta),
expiresAt,
},
});
});
return { id: hold.id, scheduleId: dto.scheduleId, seatIds: dto.seatIds, expiresAt };
return this.enrichHold(hold);
}
async getHolds(scheduleId?: string, passengerId?: string) {
const holds = await this.prisma.seatHold.findMany({
where: {
expiresAt: { gt: new Date() },
...(scheduleId ? { scheduleId } : {}),
...(passengerId ? { passengerId } : {}),
},
orderBy: { createdAt: 'desc' },
});
return Promise.all(holds.map(h => this.enrichHold(h)));
}
async getHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
return this.enrichHold(hold);
}
/**
* Resolves the opaque fareQuoteId leg encoding into human-readable station
* names and enriches the hold with schedule, seat, and leg details.
*/
private async enrichHold(hold: any) {
// Decode leg and passenger→seat mapping from createdBy JSON
let originStationId: string | null = null;
let destinationStationId: string | null = null;
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
try {
if (hold.createdBy) {
const raw = hold.createdBy;
// Guard: only parse if it looks like a JSON object, not a plain number/string
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
const meta = JSON.parse(raw);
originStationId = meta.originStationId ?? null;
destinationStationId = meta.destinationStationId ?? null;
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
}
}
} catch { /* ignore malformed createdBy */ }
const seatIds = hold.seatIds as string[];
const [schedule, originStation, destinationStation, seats] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: hold.scheduleId },
include: { train: true, originStation: true, destinationStation: true },
}),
originStationId ? this.prisma.station.findUnique({ where: { id: originStationId } }) : null,
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
this.prisma.seat.findMany({
where: { id: { in: seatIds } },
include: { coach: { include: { seatClass: true } } },
}),
]);
let originSequence: number | null = null;
let destinationSequence: number | null = null;
if (originStationId && destinationStationId) {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: hold.scheduleId, stationId: { in: [originStationId, destinationStationId] } },
select: { stationId: true, sequence: true },
});
originSequence = stopTimes.find(s => s.stationId === originStationId)?.sequence ?? null;
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
}
// Build seat map keyed by seatId for quick lookup
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
// Merge passenger→seat mapping with seat details
const passengers = passengerSeatMap.length > 0
? passengerSeatMap.map(({ passengerId, seatId }) => {
const s = seatById[seatId];
return {
passengerId,
seat: s ? {
id: s.id,
label: s.label,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
row: s.row,
col: s.col,
} : { id: seatId },
};
})
// Fallback for holds created before this change
: seatIds.map(seatId => {
const s = seatById[seatId];
return {
passengerId: hold.passengerId,
seat: s ? {
id: s.id,
label: s.label,
seatNumber: s.seatNumber,
coach: s.coach.label,
seatClass: s.coach.seatClass.name,
row: s.row,
col: s.col,
} : { id: seatId },
};
});
return {
holdId: hold.id,
expiresAt: hold.expiresAt,
createdAt: hold.createdAt,
ttlSeconds: Math.max(0, Math.floor((hold.expiresAt.getTime() - Date.now()) / 1000)),
schedule: schedule ? {
id: schedule.id,
trainNumber: schedule.train.number,
trainName: schedule.train.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
fullRouteOrigin: schedule.originStation.name,
fullRouteDestination: schedule.destinationStation.name,
} : null,
leg: {
originStationId,
originStationName: originStation?.name ?? null,
originStationCode: originStation?.code ?? null,
originSequence,
destinationStationId,
destinationStationName: destinationStation?.name ?? null,
destinationStationCode: destinationStation?.code ?? null,
destinationSequence,
},
passengers,
};
}
async releaseHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } });
await this.prisma.seatHold.delete({ where: { id: holdId } });
return { released: true };
return { released: true, holdId };
}
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
async confirmSeats(seatIds: string[]) {
// No-op for status — availability is segment-scoped via JourneySegment
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
}
async releaseSeats(seatIds: string[]) {
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
// For segment-based bookings, releasing is handled by JourneySegment deletion
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({

View File

@@ -9,7 +9,6 @@ export interface SeatHoldRequest {
passengerId: string;
originStationId: string;
destinationStationId: string;
fareQuoteId?: string;
}
export interface BookingConfirmRequest {
@@ -27,28 +26,42 @@ export class EnhancedSeatsService {
async holdSeats(request: SeatHoldRequest) {
return this.prisma.$transaction(async (tx) => {
const segments = await this.segmentsService.getJourneySegments(request.scheduleId, request.originStationId, request.destinationStationId);
const segments = await this.segmentsService.getJourneySegments(
request.scheduleId, request.originStationId, request.destinationStationId,
);
const reqFrom = Math.min(...segments.map(s => s.fromSequence));
const reqTo = Math.max(...segments.map(s => s.toSequence));
for (const seatId of request.seatIds) {
const seat = await tx.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
// segment does not overlap (another passenger may occupy a different leg)
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
const overlaps = await this.segmentsService.getOverlappingReservations(request.scheduleId, seatId, segments);
if (overlaps.length > 0) throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`);
const free = await this.segmentsService.isSeatFreeForLeg(
request.scheduleId, seatId, reqFrom, reqTo,
);
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
}
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
// Encode origin/destination into fareQuoteId so confirmBooking can resolve the leg range
// Format: "leg:{originStationId}:{destinationStationId}" (or preserve actual fareQuoteId)
const legKey = request.fareQuoteId ?? `leg:${request.originStationId}:${request.destinationStationId}`;
const seatHold = await tx.seatHold.create({
data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, fareQuoteId: legKey, expiresAt },
data: {
scheduleId: request.scheduleId,
seatIds: request.seatIds,
passengerId: request.passengerId,
// Store leg in createdBy JSON — no fareQuoteId needed
createdBy: JSON.stringify({
originStationId: request.originStationId,
destinationStationId: request.destinationStationId,
}),
expiresAt,
},
});
await tx.seat.updateMany({ where: { id: { in: request.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
// Do NOT set seat.status = HELD globally — status is segment-scoped
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
}
@@ -68,19 +81,16 @@ export class EnhancedSeatsService {
});
if (!schedule) throw new BadRequestException('Schedule not found');
// Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId")
const legKey = hold.fareQuoteId ?? '';
// Resolve the passenger's leg from createdBy JSON
let originStationId: string | undefined;
let destinationStationId: string | undefined;
if (legKey.startsWith('leg:')) {
const parts = legKey.split(':');
originStationId = parts[1];
destinationStationId = parts[2];
} else {
// Fall back to booking's own origin/destination if available
originStationId = (booking as any).originStationId;
destinationStationId = (booking as any).destinationStationId;
}
try {
if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy);
originStationId = meta.originStationId;
destinationStationId = meta.destinationStationId;
}
} catch { /* ignore */ }
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
@@ -122,11 +132,10 @@ export class EnhancedSeatsService {
}
}
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
await tx.seatHold.delete({ where: { id: request.holdId } });
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments };
});
}
@@ -171,6 +180,8 @@ export class EnhancedSeatsService {
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId);
const reqFrom = Math.min(...segments.map(s => s.fromSequence));
const reqTo = Math.max(...segments.map(s => s.toSequence));
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -181,13 +192,20 @@ export class EnhancedSeatsService {
const availableSeats = [];
for (const assignment of schedule.coachAssignments) {
for (const seat of assignment.coach.seats) {
const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, seat.id, segments);
if (overlaps.length === 0 && seat.status === 'AVAILABLE') {
// Hard-blocked seats are never available
if (seat.status === 'BLOCKED') continue;
// Availability is determined purely by segment overlap — not global seat.status
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
if (free) {
availableSeats.push({
id: seat.id, label: seat.label,
coach: assignment.coach.label,
seatClass: assignment.coach.seatClass.name,
row: seat.row, col: seat.col,
kind: seat.kind,
isWindow: seat.isWindow,
isAisle: seat.isAisle,
bedPosition: seat.bedPosition,
});
}
}

View File

@@ -1,132 +1,51 @@
import { Controller, Post, Get, Body, Query, Param } from '@nestjs/common';
import { Controller, Post, Get, Body, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { EnhancedSeatsService } from './enhanced-seats.service';
import { HoldSeatsDto, ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
import { ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
@ApiTags('Segment-based Seats')
@Controller('segments/seats')
export class SegmentSeatsController {
constructor(private enhancedSeatsService: EnhancedSeatsService) {}
@Post('hold')
@ApiOperation({
summary: 'Hold seats for specific journey segments',
description: 'Reserve seats for a partial journey (e.g., Addis Ababa → Dire Dawa) with 10-minute expiry'
})
@ApiResponse({
status: 201,
description: 'Seats held successfully',
schema: {
example: {
holdId: 'hold_123',
expiresAt: '2024-01-15T10:10:00Z',
segments: [
{ fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 },
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 },
{ fromName: 'Awash', toName: 'Dire Dawa', fromSequence: 2, toSequence: 3 }
],
seats: ['seat_1', 'seat_2']
}
}
})
@ApiResponse({ status: 409, description: 'Seats not available for requested segments' })
async holdSeats(@Body() dto: HoldSeatsDto) {
return this.enhancedSeatsService.holdSeats({
scheduleId: dto.scheduleId,
seatIds: dto.seatIds,
passengerId: dto.passengerId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
fareQuoteId: dto.fareQuoteId,
});
}
@Post('confirm')
@ApiOperation({
summary: 'Confirm booking and convert hold to reservation',
description: 'Convert seat hold to confirmed booking after payment success'
@ApiOperation({
summary: 'Confirm booking convert hold to reservation',
description: 'Call after payment succeeds. Converts the SeatHold (created via POST /seats/hold) into JourneySegment records scoped to the passenger\'s leg.',
})
@ApiResponse({
status: 200,
description: 'Booking confirmed successfully',
schema: {
example: {
bookingId: 'booking_123',
confirmedSeats: ['seat_1', 'seat_2'],
segments: [
{ fromName: 'Addis Ababa', toName: 'Adama' },
{ fromName: 'Adama', toName: 'Awash' },
{ fromName: 'Awash', toName: 'Dire Dawa' }
]
}
}
})
@ApiResponse({ status: 400, description: 'Hold expired or not found' })
async confirmBooking(@Body() dto: ConfirmBookingDto) {
@ApiResponse({ status: 200, description: 'Booking confirmed, JourneySegments created for the held leg' })
@ApiResponse({ status: 400, description: 'Hold expired or booking not found' })
confirmBooking(@Body() dto: ConfirmBookingDto) {
return this.enhancedSeatsService.confirmBooking(dto);
}
@Post('release')
@ApiOperation({
summary: 'Release seats when train reaches station',
description: 'Automatically release seats for passengers who have reached their destination'
@ApiOperation({
summary: 'Release seats when train reaches a station',
description: 'Called by the live tracking system when the train departs a station. Frees seats for passengers whose journey ended at that station.',
})
@ApiResponse({
status: 200,
description: 'Seats released successfully',
schema: {
example: {
releasedSeats: ['seat_1', 'seat_2'],
stationId: 'st_DRE'
}
}
})
async releaseSeats(@Body() dto: ReleaseSeatsDto) {
@ApiResponse({ status: 200, description: 'Seats released for passengers who reached their destination' })
releaseSeats(@Body() dto: ReleaseSeatsDto) {
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
}
@Get('availability')
@ApiOperation({
summary: 'Check seat availability for journey segments',
description: 'Get available seats for a specific origin-destination pair'
@ApiOperation({
summary: 'Get available seats for a specific leg',
description: 'Returns seats that have no overlapping reservation for the requested origindestination leg. A seat booked A→B is shown as available for B→D.',
})
@ApiResponse({
status: 200,
description: 'Seat availability retrieved',
schema: {
example: {
segments: [
{ fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 },
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 }
],
availableSeats: [
{ id: 'seat_1', label: '1A', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'A' },
{ id: 'seat_2', label: '1B', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'B' }
],
totalAvailable: 2
}
}
})
async getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
@ApiResponse({ status: 200, description: 'Available seats with coach, seat class, row, col, window/aisle/bed flags' })
getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
}
@Post('expire-holds')
@ApiOperation({
summary: 'Expire old seat holds (background job)',
description: 'Release seats from expired holds and make them available'
@ApiOperation({
summary: 'Expire stale seat holds (background job)',
description: 'Removes holds past their expiry time. Called by the scheduler every minute.',
})
@ApiResponse({
status: 200,
description: 'Expired holds processed',
schema: {
example: {
expiredHolds: 5,
releasedSeats: ['seat_1', 'seat_2', 'seat_3']
}
}
})
async expireHolds() {
@ApiResponse({ status: 200, description: 'Expired holds removed' })
expireHolds() {
return this.enhancedSeatsService.expireHolds();
}
}

View File

@@ -26,7 +26,7 @@ export class SegmentsService {
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
if (!originStop || !destStop) {
throw new BadRequestException('Origin or destination station not found on this schedule');
@@ -38,22 +38,126 @@ export class SegmentsService {
const segments: Segment[] = [];
for (let i = originStop.sequence; i < destStop.sequence; i++) {
const fromStop = stopTimes.find(st => st.sequence === i);
const toStop = stopTimes.find(st => st.sequence === i + 1);
const toStop = stopTimes.find(st => st.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
toStationId: toStop.stationId,
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name,
toStationId: toStop.stationId,
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name,
});
}
}
return segments;
}
/** True if two segment ranges overlap: [a.from, a.to) ∩ [b.from, b.to) ≠ ∅ */
/**
* Checks whether a seat is free for the requested leg [reqFrom, reqTo).
*
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
*
* This means two journeys that TOUCH at a boundary do NOT conflict:
* P1: A(1) → B(2) reqFrom=1, reqTo=2
* P2: B(2) → D(4) reqFrom=2, reqTo=4
* Check P1 vs P2: 1 < 4 AND 2 < 2 → true AND false → NO conflict ✓
*
* P3: A(1) → D(4) reqFrom=1, reqTo=4
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
*
* Sources checked:
* 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId })
* 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys
*/
async isSeatFreeForLeg(
scheduleId: string,
seatId: string,
reqFrom: number,
reqTo: number,
): Promise<boolean> {
// ── Load stop-time sequences once ────────────────────────────────────────
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) =>
stopTimes.find(s => s.stationId === stationId)?.sequence;
// ── 1. Active holds ───────────────────────────────────────────────────────
const activeHolds = await this.prisma.seatHold.findMany({
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
for (const hold of activeHolds) {
// Decode leg from createdBy JSON: { originStationId, destinationStationId, passengers }
let holdFrom: number | undefined;
let holdTo: number | undefined;
try {
if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy);
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
}
} catch { /* ignore */ }
if (holdFrom !== undefined && holdTo !== undefined) {
if (holdFrom < reqTo && reqFrom < holdTo) return false;
} else {
// Cannot resolve leg — conservative block
return false;
}
}
// ── 2. Active JourneySegments ─────────────────────────────────────────────
// Each row is one leg (e.g. A→B, B→C). We group by journeyId to get the
// full range [min(depSeq), max(arrSeq)] per journey for this seat.
const bookedLegs = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
});
// Group legs by journeyId → find the full range each journey occupies
const journeyRanges = new Map<string, { from: number; to: number }>();
for (const leg of bookedLegs) {
const depSeq = seqOf(leg.departureStationId);
const arrSeq = seqOf(leg.arrivalStationId);
if (depSeq === undefined || arrSeq === undefined) continue;
const existing = journeyRanges.get(leg.journeyId);
if (!existing) {
journeyRanges.set(leg.journeyId, { from: depSeq, to: arrSeq });
} else {
journeyRanges.set(leg.journeyId, {
from: Math.min(existing.from, depSeq),
to: Math.max(existing.to, arrSeq),
});
}
}
for (const { from, to } of journeyRanges.values()) {
// Strict overlap: existingFrom < reqTo AND reqFrom < existingTo
if (from < reqTo && reqFrom < to) return false;
}
return true;
}
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
async getOverlappingReservations(
scheduleId: string,
seatId: string,
requestedSegments: Segment[],
): Promise<{ type: string; id: string }[]> {
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
const free = await this.isSeatFreeForLeg(scheduleId, seatId, reqFrom, reqTo);
return free ? [] : [{ type: 'conflict', id: seatId }];
}
segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean {
for (const s1 of segments1) {
for (const s2 of segments2) {
@@ -62,69 +166,4 @@ export class SegmentsService {
}
return false;
}
/**
* Returns conflicts for a seat on a schedule for the requested segment range.
* Checks:
* 1. Active SeatHolds — resolved to sequence range via JourneySegment if available,
* otherwise treated as full-schedule block.
* 2. Active BookingSeats — resolved via JourneySegment sequence ranges.
*/
async getOverlappingReservations(
scheduleId: string,
seatId: string,
requestedSegments: Segment[],
) {
const overlaps: { type: string; id: string }[] = [];
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
// ── 1. Active holds ──────────────────────────────────────────────────────
const activeHolds = await this.prisma.seatHold.findMany({
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
for (const hold of activeHolds) {
// Resolve hold range from JourneySegments created at hold time
const holdSegs = await this.prisma.journeySegment.findMany({
where: { scheduleId, seatId },
include: { schedule: { include: { stopTimes: true } } },
});
if (holdSegs.length === 0) {
// No journey segments yet — conservative: treat as full-schedule conflict
overlaps.push({ type: 'hold', id: hold.id });
continue;
}
for (const js of holdSegs) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
overlaps.push({ type: 'hold', id: hold.id });
break;
}
}
}
// ── 2. Active bookings via JourneySegment ────────────────────────────────
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
include: { schedule: { include: { stopTimes: true } } },
});
for (const js of bookedSegments) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
overlaps.push({ type: 'booking', id: js.journeyId });
}
}
return overlaps;
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
/**
* Like {@link JwtGuard}, but never rejects the request.
*
* When a valid `Authorization: Bearer <jwt>` is present, `request.user` is
* populated from the JWT strategy (`{ userId, ... }`). When the token is
* missing or invalid, the request still proceeds with `request.user`
* undefined — the handler decides what to do.
*
* Used on `POST /fayda/verification/start`, which must work for both
* logged-in users (who can opt to save the verification to their account)
* and guests (anchored to a booking only).
*/
@Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') {
handleRequest<TUser = unknown>(_err: unknown, user: TUser): TUser {
return (user ?? null) as TUser;
}
}

View File

@@ -0,0 +1,71 @@
import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose';
import { generateClientAssertion } from './client-assertion.util';
describe('generateClientAssertion', () => {
let privateJwk: JWK;
let publicJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
privateJwk = await exportJWK(kp.privateKey);
publicJwk = await exportJWK(kp.publicKey);
});
it('produces a JWT verifiable with the matching public key', async () => {
const jwt = await generateClientAssertion({
clientId: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload, protectedHeader } = await jwtVerify(jwt, verifier, {
issuer: 'edr-passenger-test',
subject: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
});
expect(protectedHeader.alg).toBe('RS256');
expect(protectedHeader.typ).toBe('JWT');
expect(payload.iss).toBe('edr-passenger-test');
expect(payload.sub).toBe('edr-passenger-test');
expect(payload.aud).toBe('https://esignet.example.com/token');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.exp).toBe('number');
});
it('defaults exp to 120 seconds after iat', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(120);
});
it('honors a custom expiresIn', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
expiresIn: '5m',
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(300);
});
it('fails verification against a wrong audience', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
await expect(
jwtVerify(jwt, verifier, { audience: 'https://other/token' }),
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,22 @@
import { SignJWT, importJWK, type JWK } from 'jose';
export interface GenerateClientAssertionInput {
clientId: string;
audience: string;
privateJwk: JWK;
expiresIn?: string;
}
export async function generateClientAssertion(
input: GenerateClientAssertionInput,
): Promise<string> {
const privateKey = await importJWK(input.privateJwk, 'RS256');
return new SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(input.clientId)
.setSubject(input.clientId)
.setAudience(input.audience)
.setIssuedAt()
.setExpirationTime(input.expiresIn ?? '2m')
.sign(privateKey);
}

View File

@@ -0,0 +1,65 @@
import { createHash } from 'crypto';
import {
base64Url,
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './pkce.util';
describe('pkce.util', () => {
describe('base64Url', () => {
it('strips padding and replaces + and / with - and _', () => {
const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]);
const out = base64Url(input);
expect(out).not.toMatch(/[+/=]/);
});
});
describe('generateCodeVerifier', () => {
it('returns a base64url-safe string', () => {
expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toEqual(b);
});
it('produces at least 43 characters (RFC 7636 minimum)', () => {
expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43);
});
});
describe('generateCodeChallenge', () => {
it('equals base64url(sha256(verifier))', () => {
const verifier = 'fixed-test-verifier';
const expected = createHash('sha256')
.update(verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
expect(generateCodeChallenge(verifier)).toBe(expected);
});
it('is deterministic for the same verifier', () => {
const verifier = generateCodeVerifier();
expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier));
});
it('differs for different verifiers', () => {
expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b'));
});
});
describe('generateState', () => {
it('returns a base64url-safe string', () => {
expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
expect(generateState()).not.toEqual(generateState());
});
});
});

View File

@@ -0,0 +1,21 @@
import { createHash, randomBytes } from 'crypto';
export function base64Url(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
return base64Url(randomBytes(64));
}
export function generateCodeChallenge(codeVerifier: string): string {
return base64Url(createHash('sha256').update(codeVerifier).digest());
}
export function generateState(): string {
return base64Url(randomBytes(32));
}

View File

@@ -0,0 +1,111 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
import {
CompleteVerificationResultDto,
StartVerificationDto,
VerifaydaCallbackDto,
VerificationStatusDto,
} from './verifayda.dto';
import { VerifaydaService } from './verifayda.service';
/** Shape the JWT strategy puts on `request.user` (see common/jwt.strategy.ts). */
interface AuthedUser {
userId: string;
email?: string;
role?: string;
passengerId?: string;
}
/** Minimal slices of the Express req we touch (avoids a hard dependency on
* `@types/express`, which isn't resolved in this package). */
interface RequestWithOptionalUser {
user?: AuthedUser;
}
interface RequestWithUser {
user: AuthedUser;
}
@ApiTags('Fayda Verification')
@Controller('fayda/verification')
export class VerifaydaController {
constructor(private readonly service: VerifaydaService) {}
@Post('start')
@HttpCode(HttpStatus.OK)
@UseGuards(OptionalJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Start a VeriFayda 2.0 verification session',
description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to.
- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user; when \`saveToAccount\` is true their account is marked verified on success.
- For a **PURCHASE** flow, pass \`bookingId\` to stamp the booking's seats as Fayda-verified.
- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`,
})
@ApiOkResponse({
description: 'Authorize URL the frontend should redirect the user to.',
schema: {
example: {
authorizationUrl:
'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...',
},
},
})
async start(
@Body() dto: StartVerificationDto,
@Req() req: RequestWithOptionalUser,
): Promise<{ authorizationUrl: string }> {
const authorizationUrl = await this.service.startVerification({
purpose: dto.purpose ?? 'PURCHASE',
platform: dto.platform ?? 'WEB',
userId: req.user?.userId,
bookingId: dto.bookingId,
saveToAccount: dto.saveToAccount,
});
return { authorizationUrl };
}
@Get('complete')
@ApiOperation({
summary: 'Complete a verification (Fayda redirect / client callback lands here)',
description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
})
@ApiOkResponse({ type: CompleteVerificationResultDto })
async complete(
@Query() dto: VerifaydaCallbackDto,
): Promise<CompleteVerificationResultDto> {
return this.service.completeVerification(dto);
}
@Get('status')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: "Get the current user's Fayda verification status",
description:
'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.',
})
@ApiOkResponse({ type: VerificationStatusDto })
async status(
@Req() req: RequestWithUser,
): Promise<VerificationStatusDto> {
return this.service.getVerificationStatus(req.user.userId);
}
}

View File

@@ -0,0 +1,78 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
export class StartVerificationDto {
@ApiPropertyOptional({
enum: ['LOGIN', 'PURCHASE'],
default: 'PURCHASE',
description: 'Reason for verification.',
})
@IsOptional()
@IsIn(['LOGIN', 'PURCHASE'])
purpose?: 'LOGIN' | 'PURCHASE';
@ApiPropertyOptional({
description:
'Booking the verification should attach to (PURCHASE flow). If omitted, the session is anchored only to the user.',
})
@IsOptional()
@IsString()
bookingId?: string;
@ApiPropertyOptional({
description:
'When true and the user is logged in, copy faydaVerified=true / faydaSub onto their User record after verification.',
})
@IsOptional()
@IsBoolean()
saveToAccount?: boolean;
@ApiPropertyOptional({
enum: ['WEB', 'MOBILE'],
default: 'WEB',
description:
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
})
@IsOptional()
@IsIn(['WEB', 'MOBILE'])
platform?: 'WEB' | 'MOBILE';
}
export class CompleteVerificationResultDto {
@ApiProperty({ enum: ['LOGIN', 'PURCHASE'] })
purpose: 'LOGIN' | 'PURCHASE';
@ApiProperty() verified: boolean;
@ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' })
token?: string;
@ApiPropertyOptional({
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
})
user?: {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
};
@ApiPropertyOptional({
description: 'Verified full name from Fayda (PURCHASE flow).',
})
fullName?: string;
}
export class VerifaydaCallbackDto {
@ApiPropertyOptional() @IsOptional() @IsString() code?: string;
@ApiPropertyOptional() @IsOptional() @IsString() state?: string;
@ApiPropertyOptional() @IsOptional() @IsString() error?: string;
@ApiPropertyOptional() @IsOptional() @IsString() error_description?: string;
}
export class VerificationStatusDto {
@ApiProperty() verified: boolean;
@ApiPropertyOptional() verifiedAt?: Date;
@ApiPropertyOptional() fullName?: string;
}

View File

@@ -0,0 +1,19 @@
import { BadGatewayException, ConflictException } from '@nestjs/common';
export class FaydaTokenExchangeException extends BadGatewayException {
constructor(message = 'Fayda token exchange failed') {
super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message });
}
}
export class FaydaUserInfoException extends BadGatewayException {
constructor(message = 'Fayda userinfo fetch failed') {
super({ code: 'FAYDA_USERINFO_FAILED', message });
}
}
export class FaydaIdentityConflictException extends ConflictException {
constructor(message = 'This Fayda identity is already linked to another account') {
super({ code: 'FAYDA_IDENTITY_CONFLICT', message });
}
}

View File

@@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { VerifaydaController } from './verifayda.controller';
import { VerifaydaService } from './verifayda.service';
import { PrismaModule } from '../../common/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule],
// AuthModule re-exports JwtModule, giving us JwtService (same secret/expiry
// config as /auth/login) to mint tokens for the LOGIN flow.
imports: [PrismaModule, AuthModule],
controllers: [VerifaydaController],
providers: [VerifaydaService],
exports: [VerifaydaService],
})

View File

@@ -0,0 +1,569 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
import { VerifaydaService } from './verifayda.service';
function buildPrismaMock() {
return {
faydaVerificationSession: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
bookingSeat: {
updateMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() },
walletAccount: { create: jest.fn() },
userPreferences: { create: jest.fn() },
verifaydaVerification: { create: jest.fn() },
};
}
function buildJwtMock(): jest.Mocked<JwtService> {
return {
sign: jest.fn(() => 'signed.jwt.token'),
} as unknown as jest.Mocked<JwtService>;
}
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return {
enabled: true,
clientId: 'edr-test-client',
authorizationEndpoint: 'https://esignet.test/authorize',
tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo',
redirectUri: 'http://localhost:4000/fayda/verification/complete',
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code',
claimsLocales: 'en am',
sessionTtlMinutes: 10,
...overrides,
};
}
function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService> {
return {
get: jest.fn((key: string, defaultValue?: unknown) => {
if (key === 'fayda') return faydaConfig;
if (key === 'VERIFAYDA_ENABLED') return false;
return defaultValue;
}),
} as unknown as jest.Mocked<ConfigService>;
}
describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
let jwt: jest.Mocked<JwtService>;
let service: VerifaydaService;
let realPrivateJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
realPrivateJwk = await exportJWK(kp.privateKey);
realPrivateJwk.kty = 'RSA';
});
beforeEach(() => {
prisma = buildPrismaMock();
jwt = buildJwtMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService(
buildConfigService(cfg),
prisma as unknown as PrismaService,
jwt,
);
(global as any).fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('startVerification', () => {
it('persists a session and returns a fully-formed authorize URL', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'PURCHASE',
userId: 'user-1',
saveToAccount: true,
});
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.purpose).toBe('PURCHASE');
expect(created.platform).toBe('WEB');
expect(typeof created.state).toBe('string');
expect(typeof created.codeVerifier).toBe('string');
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
);
expect(parsed.searchParams.get('state')).toBe(created.state);
});
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'LOGIN',
platform: 'MOBILE',
});
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.platform).toBe('MOBILE');
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
);
});
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService,
jwt,
);
await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }),
).rejects.toMatchObject({ status: 503 });
});
});
describe('completeVerification — validation', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
errorCode: null,
errorDescription: null,
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
it('throws and marks failed when callback carries an error', async () => {
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({
error: 'access_denied',
error_description: 'user cancelled',
state: 'state-abc',
}),
).rejects.toMatchObject({ status: 400 });
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
await expect(service.completeVerification({})).rejects.toMatchObject({
status: 400,
});
});
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
await expect(
service.completeVerification({ code: 'c', state: 'bogus' }),
).rejects.toMatchObject({ status: 400 });
});
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ status: 'COMPLETED' }),
);
await expect(
service.completeVerification({ code: 'c', state: 'state-abc' }),
).rejects.toMatchObject({ status: 400 });
});
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
);
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
await expect(
service.completeVerification({ code: 'c', state: 'state-abc' }),
).rejects.toMatchObject({ status: 400 });
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
});
describe('completeVerification — PURCHASE', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockFetchSequence(...responses: Array<Partial<Response>>) {
const queue = responses.map((r) => ({
ok: true,
status: 200,
text: async () => '',
json: async () => ({}),
headers: new Headers({ 'content-type': 'application/json' }),
...r,
}));
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
it('stamps the booking seats and returns { verified, fullName }', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-1' }),
);
prisma.faydaVerificationSession.update.mockResolvedValue({});
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
},
);
const result = await service.completeVerification({
code: 'authcode',
state: 'state-abc',
});
expect(result).toMatchObject({
purpose: 'PURCHASE',
verified: true,
fullName: 'Test User',
});
expect(result.token).toBeUndefined();
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
where: { bookingId: 'booking-1' },
data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
});
});
it('saves to the User account when saveToAccount=true and no conflict', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
},
);
const result = await service.completeVerification({
code: 'authcode',
state: 'state-abc',
});
expect(result.verified).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
});
});
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
},
);
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('throws 502 when the token endpoint returns 4xx', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence({
ok: false,
status: 400,
text: async () => '{"error":"invalid_assertion"}',
});
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 502 });
});
it('throws 502 when userinfo is an unsupported format', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'text/plain' }),
text: async () => 'not-a-jwt-not-a-json',
},
);
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 502 });
});
it('falls back to localized name (name#en) when name is missing', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-2' }),
);
prisma.faydaVerificationSession.update.mockResolvedValue({});
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({
sub: 'fayda-sub-4',
'name#en': 'English Name',
'name#am': 'Amharic Name',
}),
},
);
const result = await service.completeVerification({
code: 'c',
state: 'state-abc',
});
expect(result.fullName).toBe('English Name');
expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
'English Name',
);
});
});
describe('completeVerification — LOGIN', () => {
function loginSession(overrides: Partial<any> = {}) {
return {
id: 'login-session',
state: 'state-login',
codeVerifier: 'verifier-xyz',
purpose: 'LOGIN',
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockLoginFetch(userInfo: Record<string, unknown>) {
const queue = [
{
ok: true,
status: 200,
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
text: async () => '',
headers: new Headers({ 'content-type': 'application/json' }),
},
{
ok: true,
status: 200,
json: async () => ({}),
text: async () => JSON.stringify(userInfo),
headers: new Headers({ 'content-type': 'application/json' }),
},
];
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
function mockUserFindUnique(bySub: any, fullUser: any) {
prisma.user.findUnique.mockImplementation(async (args: any) => {
if (args?.where?.faydaSub !== undefined) return bySub;
if (args?.where?.id !== undefined) return fullUser;
return null;
});
}
beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
});
it('creates a new user when no match and returns { token, user }', async () => {
const fullUser = {
id: 'new-user',
email: 'new@example.com',
role: 'PASSENGER',
passenger: { id: 'p-new' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({ id: 'new-user' });
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
prisma.loyaltyAccount.create.mockResolvedValue({});
prisma.walletAccount.create.mockResolvedValue({});
prisma.userPreferences.create.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result).toMatchObject({
purpose: 'LOGIN',
verified: true,
token: 'signed.jwt.token',
user: { id: 'new-user', passengerId: 'p-new' },
});
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
faydaSub: 'login-sub-1',
faydaVerified: true,
email: 'new@example.com',
}),
}),
);
expect(prisma.passenger.create).toHaveBeenCalled();
expect(jwt.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
);
});
it('logs in an existing user already linked by faydaSub', async () => {
const fullUser = {
id: 'known-user',
email: 'k@example.com',
role: 'PASSENGER',
passenger: { id: 'p-k' },
agent: null,
};
mockUserFindUnique({ id: 'known-user' }, fullUser);
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('known-user');
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('links Fayda to an existing account matched by email', async () => {
const fullUser = {
id: 'acc-1',
email: 'match@example.com',
role: 'PASSENGER',
passenger: { id: 'p-1' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
const result = await service.completeVerification({
code: 'c',
state: 'state-login',
});
expect(result.user?.id).toBe('acc-1');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'acc-1' },
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
}),
);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
mockUserFindUnique(null, null);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect(
service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
});
});
describe('getVerificationStatus', () => {
it('returns verified=true when User row has the flag', async () => {
prisma.user.findUnique.mockResolvedValue({
faydaVerified: true,
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
const result = await service.getVerificationStatus('user-1');
expect(result).toEqual({
verified: true,
verifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
});
it('returns verified=false when User row is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const result = await service.getVerificationStatus('user-x');
expect(result).toEqual({ verified: false });
});
});
});

View File

@@ -1,7 +1,35 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../common/prisma.service';
import { JwtService } from '@nestjs/jwt';
import axios, { AxiosInstance } from 'axios';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './utils/pkce.util';
import { generateClientAssertion } from './utils/client-assertion.util';
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
import {
FaydaIdentityConflictException,
FaydaTokenExchangeException,
FaydaUserInfoException,
} from './verifayda.errors';
import {
FaydaTokenResponse,
FaydaUserInfo,
NormalizedFaydaUserInfo,
VerifaydaPurpose,
} from './verifayda.types';
export interface VerifaydaPassengerData {
fullName: string;
@@ -17,38 +45,554 @@ export interface VerifaydaVerificationResult {
failureReason?: string;
}
export interface StartVerificationInput {
purpose: VerifaydaPurpose;
platform?: FaydaPlatform;
userId?: string;
bookingId?: string;
saveToAccount?: boolean;
}
export interface FaydaUserSummary {
id: string;
email: string;
role: string;
passengerId?: string;
agentId?: string;
}
/**
* Result of completing a verification. `verified` is always true on success.
* LOGIN additionally returns a JWT + user; PURCHASE returns the verified name.
*/
export interface CompleteVerificationResult {
purpose: VerifaydaPurpose;
verified: boolean;
token?: string;
user?: FaydaUserSummary;
fullName?: string;
}
@Injectable()
export class VerifaydaService {
private readonly logger = new Logger(VerifaydaService.name);
private readonly faydaConfig: FaydaConfig;
private readonly httpClient: AxiosInstance;
private readonly enabled: boolean;
private readonly apiUrl: string;
private readonly apiKey: string;
private readonly stubEnabled: boolean;
private readonly stubApiUrl: string;
private readonly stubApiKey: string;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {
this.enabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
const fayda = this.config.get<FaydaConfig>('fayda');
if (!fayda) {
throw new Error('Fayda config namespace not registered');
}
this.faydaConfig = fayda;
this.stubEnabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
this.stubApiUrl = this.config.get<string>(
'VERIFAYDA_API_URL',
'https://api.verifayda.gov.et/v2',
);
this.stubApiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
this.httpClient = axios.create({
baseURL: this.apiUrl,
baseURL: this.stubApiUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
headers: { 'Content-Type': 'application/json', 'X-API-Key': this.stubApiKey },
});
}
// ==========================================================================
// OIDC flow
// ==========================================================================
async startVerification(input: StartVerificationInput): Promise<string> {
if (!this.faydaConfig.enabled) {
throw new ServiceUnavailableException({
code: 'FAYDA_DISABLED',
message: 'Fayda integration is not enabled',
});
}
const state = generateState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const expiresAt = new Date(
Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000,
);
await this.prisma.faydaVerificationSession.create({
data: {
state,
codeVerifier,
purpose: input.purpose,
platform: input.platform ?? 'WEB',
saveToAccount: input.saveToAccount ?? false,
userId: input.userId ?? null,
bookingId: input.bookingId ?? null,
expiresAt,
},
});
this.logger.log(
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'} bookingId=${input.bookingId ?? 'none'}`,
);
return this.buildAuthorizationUrl({ state, codeChallenge });
}
async completeVerification(
query: VerifaydaCallbackDto,
): Promise<CompleteVerificationResult> {
if (query.error) {
this.logger.warn(`Fayda callback returned error: ${query.error}`);
if (query.state) {
await this.markSessionFailed(
query.state,
query.error,
query.error_description,
);
}
throw new BadRequestException({
code: 'FAYDA_AUTH_ERROR',
message: query.error,
description: query.error_description,
});
}
if (!query.code || !query.state) {
throw new BadRequestException({
code: 'FAYDA_MISSING_PARAMETERS',
message: 'code and state are required',
});
}
const session = await this.prisma.faydaVerificationSession.findUnique({
where: { state: query.state },
});
if (!session || session.status !== 'PENDING') {
this.logger.warn('Fayda complete with unknown or non-pending state');
throw new BadRequestException({
code: 'FAYDA_INVALID_STATE',
message: 'Verification session is invalid or already used',
});
}
if (session.expiresAt.getTime() < Date.now()) {
await this.markSessionFailed(query.state, 'session_expired');
throw new BadRequestException({
code: 'FAYDA_SESSION_EXPIRED',
message: 'Verification session has expired; start again',
});
}
try {
const tokens = await this.exchangeCodeForTokens(
query.code,
session.codeVerifier,
);
const userInfo = await this.fetchUserInfo(tokens.access_token);
const normalized = this.normalizeUserInfo(userInfo);
if (!normalized.sub) {
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
}
let result: CompleteVerificationResult;
if (session.purpose === 'PURCHASE') {
await this.handlePurchaseSuccess(session, normalized);
result = {
purpose: 'PURCHASE',
verified: true,
fullName: normalized.fullName,
};
} else {
const { userId } = await this.handleLoginSuccess(normalized);
const login = await this.issueLoginToken(userId);
result = { purpose: 'LOGIN', verified: true, ...login };
}
await this.prisma.faydaVerificationSession.update({
where: { id: session.id },
data: { status: 'COMPLETED', completedAt: new Date(), codeVerifier: '' },
});
this.logger.log(
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
);
return result;
} catch (err) {
const reason = this.classifyFailureReason(err);
this.logger.error(
`Fayda verification failed: reason=${reason} message=${(err as Error).message}`,
);
await this.markSessionFailed(
query.state,
reason,
(err as Error).message,
);
throw err;
}
}
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken(
userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { passenger: true, agent: true },
});
if (!user) {
// Should not happen — we just resolved/created this user.
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_FAILED',
message: 'Could not load the verified user',
});
}
const summary: FaydaUserSummary = {
id: user.id,
email: user.email,
role: user.role,
passengerId: user.passenger?.id,
agentId: user.agent?.id,
};
const token = this.jwt.sign({
sub: summary.id,
email: summary.email,
role: summary.role,
passengerId: summary.passengerId,
agentId: summary.agentId,
});
this.logger.log(`Fayda login issued token for user ${user.id}`);
return { token, user: summary };
}
async getVerificationStatus(userId: string): Promise<VerificationStatusDto> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true },
});
return {
verified: user?.faydaVerified ?? false,
verifiedAt: user?.faydaVerifiedAt ?? undefined,
fullName: user?.fullName ?? undefined,
};
}
// ==========================================================================
// OIDC internals
// ==========================================================================
private buildAuthorizationUrl(args: {
state: string;
codeChallenge: string;
}): string {
const params = new URLSearchParams({
client_id: this.faydaConfig.clientId,
response_type: 'code',
redirect_uri: this.faydaConfig.redirectUri,
scope: this.faydaConfig.scope,
state: args.state,
code_challenge: args.codeChallenge,
code_challenge_method: 'S256',
acr_values: this.faydaConfig.acrValues,
claims_locales: this.faydaConfig.claimsLocales,
});
const claims = {
userinfo: {
name: { essential: true },
phone_number: { essential: true },
email: { essential: false },
birthdate: { essential: true },
gender: { essential: false },
picture: { essential: false },
},
id_token: {},
};
params.set('claims', JSON.stringify(claims));
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
}
private async exchangeCodeForTokens(
code: string,
codeVerifier: string,
): Promise<FaydaTokenResponse> {
const clientAssertion = await generateClientAssertion({
clientId: this.faydaConfig.clientId,
audience: this.faydaConfig.tokenEndpoint,
privateJwk: this.faydaConfig.privateJwk,
});
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.faydaConfig.redirectUri,
client_id: this.faydaConfig.clientId,
client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
code_verifier: codeVerifier,
});
const response = await fetch(this.faydaConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
// ignore
}
throw new FaydaTokenExchangeException(
`Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`,
);
}
return (await response.json()) as FaydaTokenResponse;
}
private async fetchUserInfo(accessToken: string): Promise<FaydaUserInfo> {
const response = await fetch(this.faydaConfig.userInfoEndpoint, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new FaydaUserInfoException(
`Fayda userinfo endpoint returned ${response.status}`,
);
}
const contentType = response.headers.get('content-type') ?? '';
const raw = await response.text();
if (contentType.includes('application/json')) {
return JSON.parse(raw) as FaydaUserInfo;
}
// Signed JWT response — decode payload (signature verification = production TODO)
if (raw.split('.').length === 3) {
const payloadB64 = raw.split('.')[1];
const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
const json = Buffer.from(normalizedB64, 'base64').toString('utf8');
return JSON.parse(json) as FaydaUserInfo;
}
throw new FaydaUserInfoException(
'Unsupported Fayda userinfo response format',
);
}
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
return {
sub: raw.sub,
fullName: raw.name ?? raw['name#en'] ?? raw['name#am'],
phoneNumber:
raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone,
email: raw.email,
gender: raw.gender,
birthdate: raw.birthdate,
picture: raw.picture,
};
}
private async handlePurchaseSuccess(
session: {
id: string;
userId: string | null;
bookingId: string | null;
saveToAccount: boolean;
},
normalized: NormalizedFaydaUserInfo,
): Promise<void> {
if (session.bookingId) {
await this.prisma.bookingSeat.updateMany({
where: { bookingId: session.bookingId },
data: {
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
faydaVerifiedName: normalized.fullName ?? null,
},
});
}
if (session.userId && session.saveToAccount) {
const conflict = await this.prisma.user.findFirst({
where: {
faydaSub: normalized.sub,
NOT: { id: session.userId },
},
select: { id: true },
});
if (conflict) {
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: session.userId },
data: {
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
});
}
}
/**
* Resolves the User for a LOGIN flow and returns its id (the caller mints the
* JWT via {@link issueLoginToken}). Resolution order:
* 1. Existing user already linked to this Fayda `sub`.
* 2. Existing account whose email/phone matches — linked to this `sub`.
* 3. Otherwise a fresh Fayda-backed account is created.
*/
private async handleLoginSuccess(
normalized: NormalizedFaydaUserInfo,
): Promise<{ userId: string }> {
let userId: string;
const bySub = await this.prisma.user.findUnique({
where: { faydaSub: normalized.sub },
select: { id: true },
});
if (bySub) {
userId = bySub.id;
} else {
const matchers: Array<{ email?: string; phone?: string }> = [];
if (normalized.email) matchers.push({ email: normalized.email });
if (normalized.phoneNumber) matchers.push({ phone: normalized.phoneNumber });
const existing = matchers.length
? await this.prisma.user.findFirst({
where: { OR: matchers },
select: { id: true, faydaSub: true },
})
: null;
if (existing) {
if (existing.faydaSub && existing.faydaSub !== normalized.sub) {
// The matched account is already tied to a different Fayda identity.
throw new FaydaIdentityConflictException();
}
await this.prisma.user.update({
where: { id: existing.id },
data: {
faydaSub: normalized.sub,
faydaVerified: true,
faydaVerifiedAt: new Date(),
},
});
userId = existing.id;
this.logger.log(`Fayda login linked existing user ${existing.id}`);
} else {
userId = await this.createFaydaUser(normalized);
this.logger.log(`Fayda login created new user ${userId}`);
}
}
return { userId };
}
/**
* Creates a Fayda-backed User plus the same satellite rows registration makes
* (Passenger, LoyaltyAccount, WalletAccount, UserPreferences).
*
* The user has no password — `passwordHash` is set to a bcrypt of random bytes
* so password login is impossible; they authenticate only via Fayda. When
* Fayda doesn't supply an email/phone, a deterministic placeholder derived from
* the (unique) `sub` keeps the NOT NULL + unique columns satisfied.
*/
private async createFaydaUser(
normalized: NormalizedFaydaUserInfo,
): Promise<string> {
const passwordHash = await bcrypt.hash(
randomBytes(32).toString('hex'),
10,
);
const email = normalized.email ?? `fayda_${normalized.sub}@users.fayda.local`;
const phone = normalized.phoneNumber ?? `fayda:${normalized.sub}`;
const fullName = normalized.fullName ?? 'Fayda User';
const user = await this.prisma.user.create({
data: {
fullName,
email,
phone,
passwordHash,
faydaVerified: true,
faydaVerifiedAt: new Date(),
faydaSub: normalized.sub,
},
select: { id: true },
});
const passenger = await this.prisma.passenger.create({
data: { userId: user.id },
select: { id: true },
});
await this.prisma.loyaltyAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.walletAccount.create({
data: { passengerId: passenger.id },
});
await this.prisma.userPreferences.create({ data: { userId: user.id } });
return user.id;
}
private async markSessionFailed(
state: string,
errorCode: string,
errorDescription?: string,
): Promise<void> {
await this.prisma.faydaVerificationSession.updateMany({
where: { state, status: 'PENDING' },
data: {
status: 'FAILED',
errorCode,
errorDescription: errorDescription ?? null,
completedAt: new Date(),
codeVerifier: '',
},
});
}
private classifyFailureReason(err: unknown): string {
if (err instanceof FaydaIdentityConflictException) return 'identity_conflict';
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
return 'verification_failed';
}
// ==========================================================================
// DEPRECATED: legacy stub flow
// ==========================================================================
/** @deprecated Use the OIDC flow instead. Retained until cleanup. */
async verifyNationalId(
nationalId: string,
bookingId?: string,
): Promise<VerifaydaVerificationResult> {
if (!this.enabled) {
this.logger.warn('Verifayda is disabled - skipping verification');
if (!this.stubEnabled) {
this.logger.warn('Verifayda stub is disabled - skipping verification');
return {
verified: false,
failureReason: 'Verifayda integration is disabled',
@@ -62,10 +606,8 @@ export class VerifaydaService {
};
try {
this.logger.log(`Verifying national ID via Verifayda 2.0`);
this.logger.log('Verifying national ID via legacy Verifayda stub');
const response = await this.httpClient.post('/verify', requestPayload);
const { data } = response;
if (data.status === 'verified' && data.citizen) {
@@ -88,36 +630,24 @@ export class VerifaydaService {
},
});
this.logger.log('Verifayda verification successful');
return { verified: true, passengerData };
}
return {
verified: true,
passengerData,
};
} else {
const failureReason = data.message || 'Verification failed';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
responsePayload: data,
verified: false,
failureReason,
},
});
this.logger.warn(`Verifayda verification failed: ${failureReason}`);
return {
const failureReason = data.message || 'Verification failed';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
responsePayload: data,
verified: false,
failureReason,
};
}
},
});
return { verified: false, failureReason };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Unknown error';
const errorMessage =
error.response?.data?.message || error.message || 'Unknown error';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
@@ -127,16 +657,15 @@ export class VerifaydaService {
failureReason: errorMessage,
},
});
this.logger.error(`Verifayda API error: ${errorMessage}`);
this.logger.error(`Verifayda stub error: ${errorMessage}`);
throw new BadRequestException(
`National ID verification failed: ${errorMessage}`,
);
}
}
/** @deprecated Use `faydaConfig.enabled` for the OIDC flow. */
isEnabled(): boolean {
return this.enabled;
return this.stubEnabled;
}
}

View File

@@ -0,0 +1,36 @@
export type VerifaydaPurpose = 'LOGIN' | 'PURCHASE';
export interface FaydaTokenResponse {
access_token: string;
id_token?: string;
token_type: string;
expires_in?: number;
scope?: string;
}
export interface FaydaUserInfo {
sub: string;
name?: string;
'name#en'?: string;
'name#am'?: string;
phone_number?: string;
'phone_number#en'?: string;
'phone_number#am'?: string;
phone?: string;
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
address?: Record<string, unknown>;
[key: string]: unknown;
}
export interface NormalizedFaydaUserInfo {
sub: string;
fullName?: string;
phoneNumber?: string;
email?: string;
gender?: string;
birthdate?: string;
picture?: string;
}

View File

@@ -1,22 +1,25 @@
// ── Enums ──────────────────────────────────────────────────────────────────────
// ── Enums ────────────────────────────────────────────────────────────────
export type TripStatus = 'SCHEDULED' | 'BOARDING' | 'EN_ROUTE' | 'ARRIVED' | 'CANCELLED' | 'DELAYED';
export type SeatStatus = 'AVAILABLE' | 'HELD' | 'BOOKED' | 'BLOCKED';
export type ServiceClass = 'ECONOMY' | 'BUSINESS' | 'FIRST';
export type BookingStatus = 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW';
export type PaymentMethod = 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'CARD' | 'WALLET';
// ── Station ────────────────────────────────────────────────────────────────────
// ── Station ────────────────────────────────────────────────────────────────
export interface IStation {
id: string;
code: string;
name: string;
city: string;
country: string;
timezone: string;
lat: number;
lng: number;
createdAt: string | Date;
updatedAt: string | Date;
}
// ── Trip / Schedule ────────────────────────────────────────────────────────────
// ── Trip / Schedule ────────────────────────────────────────────────────────
export interface ITripStation {
id: string;
code: string;
@@ -62,7 +65,7 @@ export interface IStopTime {
station: IStation;
}
// ── Seat ───────────────────────────────────────────────────────────────────────
// ── Seat ────────────────────────────────────────────────────────────────────
export interface ISeat {
id: string;
number: string; // label from API
@@ -81,7 +84,7 @@ export interface ISeatMap {
coaches: ICoach[];
}
// ── Segment-based seats ────────────────────────────────────────────────────────
// ── Segment-based seats ────────────────────────────────────────────────────
export interface ISegment {
fromStationId: string;
toStationId: string;
@@ -119,7 +122,7 @@ export interface ISegmentConfirmResult {
segments: Array<{ fromStationId: string; toStationId: string; fromSequence: number; toSequence: number }>;
}
// ── Booking ────────────────────────────────────────────────────────────────────
// ── Booking ────────────────────────────────────────────────────────────────
export interface IBooking {
id: string;
bookingRef: string;
@@ -140,7 +143,7 @@ export interface IBooking {
payment?: { method: PaymentMethod; status: string };
}
// ── Ticket ─────────────────────────────────────────────────────────────────────
// ── Ticket ─────────────────────────────────────────────────────────────────
export interface ITicket {
id: string;
bookingId: string;
@@ -158,7 +161,7 @@ export interface ITicket {
qrPayload: string;
}
// ── Fare quote ─────────────────────────────────────────────────────────────────
// ── Fare quote ─────────────────────────────────────────────────────────────
export interface IFareQuote {
tripId: string;
serviceClass: ServiceClass;
@@ -171,7 +174,7 @@ export interface IFareQuote {
currency: string;
}
// ── Passenger ─────────────────────────────────────────────────────────────────
// ── Passenger ──────────────────────────────────────────────────────────────
export interface IPassengerProfile {
id: string;
fullName: string;
@@ -181,7 +184,7 @@ export interface IPassengerProfile {
bookings: IBooking[];
}
// ── Loyalty ────────────────────────────────────────────────────────────────────
// ── Loyalty ────────────────────────────────────────────────────────────────
export interface ILoyaltyAccount {
id: string;
passengerId: string;
@@ -192,7 +195,7 @@ export interface ILoyaltyAccount {
tierProgressPercent: number;
}
// ── Wallet ─────────────────────────────────────────────────────────────────────
// ── Wallet ─────────────────────────────────────────────────────────────────
export interface IWallet {
id: string;
passengerId: string;
@@ -200,7 +203,7 @@ export interface IWallet {
currency: string;
}
// ── Notification ───────────────────────────────────────────────────────────────
// ── Notification ───────────────────────────────────────────────────────────
export interface INotification {
id: string;
passengerId: string;
@@ -212,7 +215,7 @@ export interface INotification {
createdAt: string;
}
// ── Promotion ──────────────────────────────────────────────────────────────────
// ── Promotion ──────────────────────────────────────────────────────────────
export interface IPromotion {
id: string;
title: string;
@@ -225,7 +228,7 @@ export interface IPromotion {
active: boolean;
}
// ── Live tracking ──────────────────────────────────────────────────────────────
// ── Live tracking ──────────────────────────────────────────────────────────
export interface ILiveStatus {
tripId: string;
trainName: string;
@@ -241,7 +244,7 @@ export interface ILiveStatus {
updatedAt: string;
}
// ── Dashboard ──────────────────────────────────────────────────────────────────
// ── Dashboard ──────────────────────────────────────────────────────────────
export interface IDashboard {
user: { firstName: string; greetingKey: 'MORNING' | 'AFTERNOON' | 'EVENING' };
upcomingTicket: {

3
pnpm-lock.yaml generated
View File

@@ -323,6 +323,9 @@ importers:
class-validator:
specifier: ^0.14.0
version: 0.14.4
jose:
specifier: ^5.10.0
version: 5.10.0
passport:
specifier: ^0.7.0
version: 0.7.0