diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index adaaee399..cf877961d 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -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= diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 0331adb9b..1459ce98d 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql new file mode 100644 index 000000000..da38b0502 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260525134854_add_fayda_oidc_verification/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql new file mode 100644 index 000000000..64c577ff5 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260527080312_add_platform_and_authcode/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT, +ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 342944edb..6cbefd93f 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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") +} + diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 261463e27..16178a1b4 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts new file mode 100644 index 000000000..e0bce45c6 --- /dev/null +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -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; + 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, + }; +}); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 4b8b6885c..4fe3d1473 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -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") diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 3fcfce179..8cf931bad 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -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 { 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; + } } diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts new file mode 100644 index 000000000..1d35f6b61 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -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(); + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts new file mode 100644 index 000000000..59e863cea --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts new file mode 100644 index 000000000..25718cc8f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts @@ -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, + ); + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts new file mode 100644 index 000000000..536ddaa3b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -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 = { + 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; +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts new file mode 100644 index 000000000..fefdfb9a9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts new file mode 100644 index 000000000..b0411718f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -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.', + ); + } +} diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 07d0362af..1f122cd41 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -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); } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 9df073e74..5eca9f1be 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -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], diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 17f2b554b..36e8a33ed 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -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> { + const statusMap = new Map(); + 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 }; } } diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts index 5228590fb..baadcf90c 100644 --- a/apps/edr-passenger-api/src/modules/search/search.module.ts +++ b/apps/edr-passenger-api/src/modules/search/search.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9141da4cb..c534586dd 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -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 { - // 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 = { 'Economy Regular': 45000, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 4c93b5f61..a555f2513 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -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({ diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 6af6fe203..4818fe59f 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -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[]; } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index c9394983d..014e0918a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 606cb4902..96cf4e1bf 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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> { + const statusMap = new Map(); + + 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 { const seats = await this.prisma.seat.findMany({ diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts index 275ab077f..88fd636b6 100644 --- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts @@ -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, }); } } diff --git a/apps/edr-passenger-api/src/modules/segments/segments.controller.ts b/apps/edr-passenger-api/src/modules/segments/segments.controller.ts index 5a45e38c5..cd62a3064 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.controller.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.controller.ts @@ -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 origin→destination 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(); } } \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 8fe525bcb..2eef0302e 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -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 { + // ── 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(); + 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; - } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..5f5fac19b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/optional-jwt.guard.ts @@ -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 ` 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(_err: unknown, user: TUser): TUser { + return (user ?? null) as TUser; + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -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 { + 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); +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -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()); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/utils/pkce.util.ts @@ -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)); +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..f1eb25e8e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -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 { + 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 { + return this.service.getVerificationStatus(req.user.userId); + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..005a3e517 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.errors.ts @@ -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 }); + } +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts index c14ba3f1f..d850b1dbf 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.module.ts @@ -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], }) diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts new file mode 100644 index 000000000..e4b8cb790 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -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 { + return { + sign: jest.fn(() => 'signed.jwt.token'), + } as unknown as jest.Mocked; +} + +function buildConfig(overrides?: Partial): 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 { + 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; +} + +describe('VerifaydaService (OIDC, client-callback)', () => { + let prisma: ReturnType; + let jwt: jest.Mocked; + 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 = {}) { + 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 = {}) { + 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>) { + 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 = {}) { + 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) { + 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 }); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index d86ad98a2..7a929c3c5 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -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('VERIFAYDA_ENABLED', false); - this.apiUrl = this.config.get('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2'); - this.apiKey = this.config.get('VERIFAYDA_API_KEY', ''); + const fayda = this.config.get('fayda'); + if (!fayda) { + throw new Error('Fayda config namespace not registered'); + } + this.faydaConfig = fayda; + this.stubEnabled = this.config.get('VERIFAYDA_ENABLED', false); + this.stubApiUrl = this.config.get( + 'VERIFAYDA_API_URL', + 'https://api.verifayda.gov.et/v2', + ); + this.stubApiKey = this.config.get('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { - 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; } } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..7c7335c34 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -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; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + fullName?: string; + phoneNumber?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; +} diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts index 09b7c9fc2..b206daa6a 100644 --- a/apps/edr-passenger-web/portal/src/types/index.ts +++ b/apps/edr-passenger-web/portal/src/types/index.ts @@ -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: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0856ff3f4..e33f0011c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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