diff --git a/apps/edr-passenger-api/prisma/migrations/20260818000001_add_audit_log_actor_identity/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260818000001_add_audit_log_actor_identity/migration.sql new file mode 100644 index 000000000..0b19a49b8 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260818000001_add_audit_log_actor_identity/migration.sql @@ -0,0 +1,17 @@ +-- The audit trail has to answer "who did this" without a cross-schema join: Prisma only knows +-- the `passenger` schema, and an IAM user that is later deleted would take the answer with it. +-- So the actor's name and phone are denormalized onto the row at write time, the same way +-- SeatBlock.blockedByName was added for the blocked-seat report. +-- +-- Purely additive: both columns are nullable and the index is new, so rows written before this +-- migration keep working and simply report as System / Unknown in the backoffice. + +-- AlterTable +ALTER TABLE "passenger"."AuditLog" + ADD COLUMN IF NOT EXISTS "userName" TEXT, + ADD COLUMN IF NOT EXISTS "userPhone" TEXT; + +-- CreateIndex: the default Audit Logs listing sorts createdAt DESC with no other filter, which +-- neither existing composite index serves. Broadening audit coverage materially increases the +-- row count behind that sort. +CREATE INDEX IF NOT EXISTS "AuditLog_createdAt_idx" ON "passenger"."AuditLog"("createdAt"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 7bca3116d..26f93bd43 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1325,6 +1325,11 @@ model ExcessBaggageCharge { model AuditLog { id String @id @default(uuid()) iamUserId String? + /// Actor's display name, denormalized at write time so a reader needs no cross-schema + /// lookup into iam.users and the trail survives the IAM user being deleted. + userName String? + /// Actor's contact number, denormalized for the same reason. + userPhone String? action String entityType String entityId String? @@ -1335,6 +1340,7 @@ model AuditLog { createdAt DateTime @default(now()) @@index([iamUserId, createdAt]) @@index([entityType, entityId]) + @@index([createdAt]) @@schema("passenger") } diff --git a/apps/edr-passenger-api/src/common/acting-user.ts b/apps/edr-passenger-api/src/common/acting-user.ts index 630e307a8..8229096d9 100644 --- a/apps/edr-passenger-api/src/common/acting-user.ts +++ b/apps/edr-passenger-api/src/common/acting-user.ts @@ -21,6 +21,7 @@ export interface ActingUserClaims { name?: ActingUserName | string | null; username?: string; email?: string; + phoneNumber?: string; } /** The minimal Express request shape needed to reach the authenticated user. */ @@ -34,6 +35,8 @@ export interface ActingUser { id: string; /** Human-readable name, denormalized alongside the id. */ name: string; + /** Contact number, denormalized alongside the id. Absent on tokens that omit it. */ + phone?: string; } /** @@ -48,7 +51,11 @@ export function resolveActingUser(req: RequestWithActingUser): ActingUser | null const id = claims?.id ?? claims?.sub; if (!id) return null; - return { id, name: resolveActingUserName(claims) }; + return { + id, + name: resolveActingUserName(claims), + phone: claims?.phoneNumber?.trim() || undefined, + }; } function resolveActingUserName(claims: ActingUserClaims | undefined): string { diff --git a/apps/edr-passenger-api/src/common/audit-snapshot.ts b/apps/edr-passenger-api/src/common/audit-snapshot.ts new file mode 100644 index 000000000..602a723f5 --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit-snapshot.ts @@ -0,0 +1,124 @@ +/** + * Building the `oldData` / `newData` payloads on an audit row. + * + * Audit rows are retained for a year and readable by anyone with `audit:view`, so they must + * carry enough to explain a change and nothing more. Dumping a whole Prisma row is the easy + * mistake: it drags along payment tokens, contact details and identity documents that the + * reader never needed. `snapshot()` inverts the default — you list what to keep. + */ + +/** + * Words that must never reach an audit row, whatever the caller asked for. + * + * Matched against the key's own words rather than as raw substrings — a substring test would + * redact `shipping` for containing "pin" and miss nothing in exchange. + */ +const DENIED_WORDS = new Set([ + 'password', + 'passcode', + 'secret', + 'token', + 'otp', + 'pin', + 'authorization', + 'auth', + 'apikey', + 'passport', + 'nationalid', + 'fayda', + 'ssn', +]); + +/** Value written in place of a denied field, so the reader knows something was withheld. */ +const REDACTED = '[redacted]'; + +/** + * Splits `paymentToken`, `payment_token` and `PAYMENT_TOKEN` alike into ['payment', 'token'], + * then reports whether any word is denied. + */ +function isDeniedKey(key: string): boolean { + const words = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .map((w) => w.toLowerCase()) + .filter(Boolean); + + // `nationalId` splits into ['national','id'], so check the joined form too. + return words.some((w) => DENIED_WORDS.has(w)) || DENIED_WORDS.has(words.join('')); +} + +/** + * Picks `keys` off `entity`, dropping anything absent and redacting anything sensitive. + * + * Returns `undefined` for a null/undefined entity so a missing "before" state simply omits + * `oldData` rather than storing `{}`. + */ +export function snapshot( + entity: T | null | undefined, + keys: readonly (keyof T & string)[], +): Record | undefined { + if (!entity) return undefined; + + const out: Record = {}; + for (const key of keys) { + const value = (entity as Record)[key]; + if (value === undefined) continue; + out[key] = isDeniedKey(key) ? REDACTED : normalize(value); + } + return out; +} + +/** + * Same guarantees as {@link snapshot} for a payload assembled by hand rather than picked off an + * entity — the sweeps and gate branches build their context inline. + */ +export function auditPayload( + data: Record | null | undefined, +): Record | undefined { + if (!data) return undefined; + + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (value === undefined) continue; + out[key] = isDeniedKey(key) ? REDACTED : normalize(value); + } + return out; +} + +/** + * Prisma hands back `Date` and `Decimal` objects. `JSON.stringify` would render a Decimal as + * `{}`, so flatten both to something a reader can compare across two snapshots. + */ +function normalize(value: unknown): unknown { + if (value instanceof Date) return value.toISOString(); + if (value && typeof value === 'object' && typeof (value as any).toFixed === 'function') { + return (value as any).toString(); + } + return value; +} + +/** + * Reduces a before/after pair to only the fields that actually moved. + * + * Used where the "before" row is wide but the edit is narrow (a schedule status flip, a fare + * price change) so the audit row shows the change rather than burying it. + */ +export function changedFields( + before: Record | undefined, + after: Record | undefined, +): { oldData?: Record; newData?: Record } { + if (!before || !after) return { oldData: before, newData: after }; + + const oldData: Record = {}; + const newData: Record = {}; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (JSON.stringify(before[key]) === JSON.stringify(after[key])) continue; + oldData[key] = before[key]; + newData[key] = after[key]; + } + + return { + oldData: Object.keys(oldData).length ? oldData : undefined, + newData: Object.keys(newData).length ? newData : undefined, + }; +} diff --git a/apps/edr-passenger-api/src/common/audit.actions.ts b/apps/edr-passenger-api/src/common/audit.actions.ts new file mode 100644 index 000000000..f9ee389db --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.actions.ts @@ -0,0 +1,91 @@ +/** + * The closed vocabulary for audit rows. + * + * `AuditLog.action` and `AuditLog.entityType` are plain `String` columns, so nothing at the + * database level stops a caller inventing `created` or `schedule_updated`. These two maps are + * the convention: uppercase, semantic, and imported rather than typed inline. The pre-existing + * rows use `CREATE`/`UPDATE`/`DELETE`/`VERIFY`, so those keep their meaning and the rest extend + * them — no historical row changes shape. + */ + +export const AUDIT_ACTIONS = { + // ── CRUD ─────────────────────────────────────────────────────────────────── + CREATE: 'CREATE', + UPDATE: 'UPDATE', + DELETE: 'DELETE', + /** Undo of a soft delete (train restore, seat undo-remove, ticket restore). */ + RESTORE: 'RESTORE', + + // ── Explicit state transitions ───────────────────────────────────────────── + /** A status column moved. Prefer this over UPDATE when status is the point of the change. */ + STATUS_CHANGE: 'STATUS_CHANGE', + CANCEL: 'CANCEL', + + // ── Seats & coaches ──────────────────────────────────────────────────────── + BLOCK: 'BLOCK', + UNBLOCK: 'UNBLOCK', + ASSIGN: 'ASSIGN', + UNASSIGN: 'UNASSIGN', + + // ── Charges & money ──────────────────────────────────────────────────────── + WAIVE: 'WAIVE', + PAY: 'PAY', + REFUND: 'REFUND', + /** Re-sending a payment link — no state change, but it re-exposes a payment token. */ + RESEND: 'RESEND', + + // ── Gate ─────────────────────────────────────────────────────────────────── + BOARD: 'BOARD', + BOARD_DENIED: 'BOARD_DENIED', + /** + * Retained for backward-compatible filtering of gate rows written before BOARD existed. + * New code uses BOARD / BOARD_DENIED. + */ + VERIFY: 'VERIFY', + + // ── Bulk / sweeps ────────────────────────────────────────────────────────── + BULK_CREATE: 'BULK_CREATE', + BULK_UPDATE: 'BULK_UPDATE', + SYNC: 'SYNC', + IMPORT: 'IMPORT', +} as const; + +export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; + +/** + * Entity names as they appear in `AuditLog.entityType`. These mirror the Prisma model names so + * a reader can go from an audit row straight to the table, and they back the backoffice + * Audit Logs filter dropdown. + */ +export const AUDIT_ENTITIES = { + // Master data + Station: 'Station', + Train: 'Train', + Coach: 'Coach', + CoachType: 'CoachType', + Seat: 'Seat', + SeatClass: 'SeatClass', + Route: 'Route', + RouteStop: 'RouteStop', + RouteCoachTemplate: 'RouteCoachTemplate', + Schedule: 'Schedule', + ScheduleFare: 'ScheduleFare', + CoachAssignment: 'CoachAssignment', + + // Finance / tariff + FareRule: 'FareRule', + RouteFareRule: 'RouteFareRule', + SegmentFareRule: 'SegmentFareRule', + Currency: 'Currency', + Payment: 'Payment', + PaymentMethod: 'PaymentMethod', + SupplementaryCharge: 'SupplementaryCharge', + ExcessBaggageCharge: 'ExcessBaggageCharge', + BaggageAllowance: 'BaggageAllowance', + + // Operations + Ticket: 'Ticket', + Booking: 'Booking', +} as const; + +export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES]; diff --git a/apps/edr-passenger-api/src/common/audit.service.spec.ts b/apps/edr-passenger-api/src/common/audit.service.spec.ts new file mode 100644 index 000000000..52e54df68 --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.service.spec.ts @@ -0,0 +1,224 @@ +import { AuditService } from './audit.service'; +import { snapshot, auditPayload, changedFields } from './audit-snapshot'; + +/** + * The audit primitive is the reason ~47 of the 57 existing call sites wrote a NULL actor: it + * held the request (for IP/user-agent) but never read `request.user`. These pin the fix — the + * actor is resolved from the guarded session, callers cannot be trusted to pass it, and a + * logging failure never propagates into the business operation it describes. + */ +describe('AuditService', () => { + const REQUEST = { + user: { + id: 'iam-user-1', + name: { en: 'Abebe Kebede', am: 'አበበ ከበደ' }, + username: 'abebe.k', + email: 'abebe@edr.et', + phoneNumber: '+251911223344', + }, + headers: { + 'x-forwarded-for': '10.1.2.3, 172.16.0.1', + 'user-agent': 'Mozilla/5.0 (Backoffice)', + }, + socket: { remoteAddress: '127.0.0.1' }, + }; + + const build = (request?: any) => { + const create = jest.fn().mockResolvedValue({}); + const prisma = { auditLog: { create, count: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() } }; + return { service: new AuditService(prisma as any, request), create, prisma }; + }; + + const written = (create: jest.Mock) => create.mock.calls[0][0].data; + + describe('actor resolution', () => { + it('takes the actor from the authenticated request when the caller omits one', async () => { + const { service, create } = build(REQUEST); + await service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' }); + + expect(written(create)).toMatchObject({ + iamUserId: 'iam-user-1', + userName: 'Abebe Kebede', + userPhone: '+251911223344', + }); + }); + + it('ignores nothing the request says — a body cannot smuggle in a different actor', async () => { + const { service, create } = build({ + ...REQUEST, + // A request body field never reaches AuditService; only `request.user` does. + body: { validatorId: 'someone-else', waivedBy: 'not-me' }, + }); + await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-1' }); + + expect(written(create).iamUserId).toBe('iam-user-1'); + }); + + it('lets a system path pass its own actor explicitly', async () => { + const { service, create } = build(REQUEST); + await service.log({ + action: 'STATUS_CHANGE', + entityType: 'Schedule', + entityId: 'sc-1', + userId: 'SYSTEM', + userName: 'Reconciliation cron', + }); + + expect(written(create)).toMatchObject({ iamUserId: 'SYSTEM', userName: 'Reconciliation cron' }); + }); + + it('records a null actor outside an HTTP request rather than inventing one', async () => { + const { service, create } = build(undefined); + await service.log({ action: 'PAY', entityType: 'ExcessBaggageCharge', entityId: 'c-1' }); + + expect(written(create)).toMatchObject({ iamUserId: null, userName: null, userPhone: null }); + }); + + it('falls back through the name shapes IAM actually returns', async () => { + const { service, create } = build({ user: { sub: 'legacy-id', username: 'gate.agent' }, headers: {} }); + await service.log({ action: 'BOARD', entityType: 'Ticket', entityId: 't-2' }); + + expect(written(create)).toMatchObject({ iamUserId: 'legacy-id', userName: 'gate.agent' }); + }); + }); + + describe('request metadata', () => { + it('captures the client IP and user-agent', async () => { + const { service, create } = build(REQUEST); + await service.log({ action: 'UPDATE', entityType: 'Train', entityId: 'tr-1' }); + + expect(written(create)).toMatchObject({ + ipAddress: '10.1.2.3', + userAgent: 'Mozilla/5.0 (Backoffice)', + }); + }); + + it('still writes the row when the request carries no headers', async () => { + // Queue consumers resolve this service with a bare stub; reading through `headers` + // unguarded used to throw and silently drop the whole row. + const { service, create } = build({ user: { id: 'iam-user-1' }, ip: '10.9.9.9' }); + await service.log({ action: 'PAY', entityType: 'SupplementaryCharge', entityId: 'sc-1' }); + + expect(create).toHaveBeenCalledTimes(1); + expect(written(create)).toMatchObject({ ipAddress: '10.9.9.9', userAgent: '' }); + }); + + it('leaves IP and user-agent blank with no request at all', async () => { + const { service, create } = build(undefined); + await service.log({ action: 'DELETE', entityType: 'Seat', entityId: 's-1' }); + + expect(written(create)).toMatchObject({ ipAddress: '', userAgent: '' }); + }); + }); + + describe('failure containment', () => { + it('swallows a write failure instead of breaking the operation it describes', async () => { + const create = jest.fn().mockRejectedValue(new Error('db down')); + const service = new AuditService({ auditLog: { create } } as any, REQUEST); + + await expect( + service.log({ action: 'CREATE', entityType: 'Station', entityId: 'st-1' }), + ).resolves.toBeUndefined(); + }); + }); + + describe('getLogs', () => { + const withRows = () => { + const findMany = jest.fn().mockResolvedValue([]); + const count = jest.fn().mockResolvedValue(0); + const prisma = { auditLog: { findMany, count, create: jest.fn() } }; + return { service: new AuditService(prisma as any, REQUEST), findMany, count }; + }; + + it('caps the page size so a caller cannot ask for the whole table', async () => { + const { service, findMany } = withRows(); + await service.getLogs({ limit: 5000 }); + expect(findMany.mock.calls[0][0].take).toBe(200); + }); + + it('defaults to the newest 50 rows', async () => { + const { service, findMany } = withRows(); + await service.getLogs(); + expect(findMany.mock.calls[0][0]).toMatchObject({ + take: 50, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('filters by exact actor and a date range', async () => { + const { service, findMany } = withRows(); + await service.getLogs({ iamUserId: 'iam-user-1', from: '2026-08-01', to: '2026-08-18' }); + + const where = findMany.mock.calls[0][0].where; + expect(where.iamUserId).toBe('iam-user-1'); + expect(where.createdAt.gte).toEqual(new Date('2026-08-01')); + expect(where.createdAt.lte).toEqual(new Date('2026-08-18')); + }); + + it('ignores an unparseable date instead of returning nothing', async () => { + const { service, findMany } = withRows(); + await service.getLogs({ from: 'not-a-date' }); + expect(findMany.mock.calls[0][0].where.createdAt).toBeUndefined(); + }); + + it('searches the denormalized actor name and phone, not just ids', async () => { + const { service, findMany } = withRows(); + await service.getLogs({ search: 'abebe' }); + + const fields = findMany.mock.calls[0][0].where.OR.map((c: any) => Object.keys(c)[0]); + expect(fields).toEqual( + expect.arrayContaining(['entityId', 'iamUserId', 'userName', 'userPhone']), + ); + }); + }); +}); + +describe('audit snapshot helpers', () => { + it('keeps only the listed fields', () => { + const entity = { id: 'x', name: 'Dire Dawa', code: 'DD', internalNote: 'do not log' }; + expect(snapshot(entity, ['name', 'code'])).toEqual({ name: 'Dire Dawa', code: 'DD' }); + }); + + it('drops absent fields rather than writing undefined', () => { + expect(snapshot({ name: 'X', code: undefined } as any, ['name', 'code'])).toEqual({ name: 'X' }); + }); + + it('returns undefined for a missing entity so oldData is simply omitted', () => { + expect(snapshot(null, ['name'] as any)).toBeUndefined(); + }); + + it('redacts credentials even when a caller asks for them', () => { + const charge = { paymentToken: 'tok-live-1', amountMinor: 1000 } as any; + expect(snapshot(charge, ['paymentToken', 'amountMinor'])).toEqual({ + paymentToken: '[redacted]', + amountMinor: 1000, + }); + }); + + it('redacts on word boundaries, not raw substrings', () => { + // "shipping" contains the letters of "pin"; it is not a credential. + const row = { shippingNote: 'leave at gate', otp: '4530', national_id: 'ETH-1' } as any; + expect(auditPayload(row)).toEqual({ + shippingNote: 'leave at gate', + otp: '[redacted]', + national_id: '[redacted]', + }); + }); + + it('flattens dates so two snapshots can be compared', () => { + const at = new Date('2026-08-18T06:00:00.000Z'); + expect(snapshot({ departureAt: at } as any, ['departureAt'])).toEqual({ + departureAt: '2026-08-18T06:00:00.000Z', + }); + }); + + it('reduces a before/after pair to the fields that actually moved', () => { + const before = { status: 'SCHEDULED', departureTime: '08:00' }; + const after = { status: 'CANCELLED', departureTime: '08:00' }; + expect(changedFields(before, after)).toEqual({ + oldData: { status: 'SCHEDULED' }, + newData: { status: 'CANCELLED' }, + }); + }); +}); diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts index cff5403da..496c13655 100644 --- a/apps/edr-passenger-api/src/common/audit.service.ts +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -1,6 +1,38 @@ import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { PrismaService } from './prisma.service'; +import { AuditAction } from './audit.actions'; +import { resolveActingUser } from './acting-user'; + +export interface AuditLogInput { + /** + * Overrides the actor. Leave it out on any request-scoped path — `log()` resolves the + * authenticated IAM user from the request, which is the only source a caller cannot forge. + */ + userId?: string; + userName?: string; + userPhone?: string; + /** See `AUDIT_ACTIONS`. Widened to `string` so the 57 pre-existing call sites still compile. */ + action: AuditAction | string; + entityType: string; + entityId?: string; + oldData?: any; + newData?: any; +} + +export interface AuditLogFilters { + search?: string; + action?: string; + entityType?: string; + /** Exact IAM user id — "everything this staff member did". */ + iamUserId?: string; + /** Inclusive lower bound on `createdAt`. */ + from?: Date | string; + /** Inclusive upper bound on `createdAt`. */ + to?: Date | string; + limit?: number; + offset?: number; +} @Injectable() export class AuditService { @@ -10,21 +42,21 @@ export class AuditService { @Optional() @Inject(REQUEST) private request?: any, ) {} - async log(input: { - userId?: string; - action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string; - entityType: string; - entityId?: string; - oldData?: any; - newData?: any; - }) { + async log(input: AuditLogInput) { try { + // The actor is read off the guarded request rather than trusted from the caller, so a + // request body can never decide whose name lands in the trail. Callers may still pass + // `userId` explicitly for system paths (crons, queue consumers) that have no request. + const actor = this.request ? resolveActingUser(this.request) : null; + const ipAddress = this.getIpAddress(); const userAgent = this.getUserAgent(); await this.prisma.auditLog.create({ data: { - iamUserId: input.userId, + iamUserId: input.userId ?? actor?.id ?? null, + userName: input.userName ?? actor?.name ?? null, + userPhone: input.userPhone ?? actor?.phone ?? null, action: input.action, entityType: input.entityType, entityId: input.entityId, @@ -41,11 +73,14 @@ export class AuditService { } private getIpAddress(): string { - if (!this.request) return ''; - + // `headers` is absent on non-HTTP contexts (queue consumers resolve this service with a + // bare request stub); reading through it unguarded used to throw and silently drop the row. + const headers = this.request?.headers; + if (!headers) return this.request?.ip || ''; + return ( - this.request.headers['x-forwarded-for']?.split(',')[0].trim() || - this.request.headers['x-real-ip'] || + headers['x-forwarded-for']?.split(',')[0].trim() || + headers['x-real-ip'] || this.request.connection?.remoteAddress || this.request.socket?.remoteAddress || this.request.ip || @@ -57,13 +92,15 @@ export class AuditService { return this.request?.headers?.['user-agent'] || ''; } - async getLogs(filters: any = {}) { + async getLogs(filters: AuditLogFilters = {}) { const where: any = {}; if (filters.search) { where.OR = [ { entityId: { contains: filters.search, mode: 'insensitive' } }, { iamUserId: { contains: filters.search, mode: 'insensitive' } }, + { userName: { contains: filters.search, mode: 'insensitive' } }, + { userPhone: { contains: filters.search, mode: 'insensitive' } }, ]; } @@ -75,6 +112,15 @@ export class AuditService { where.entityType = filters.entityType; } + if (filters.iamUserId) { + where.iamUserId = filters.iamUserId; + } + + const createdAt = this.dateRange(filters.from, filters.to); + if (createdAt) { + where.createdAt = createdAt; + } + const limit = Math.min(filters.limit ?? 50, 200); const offset = filters.offset ?? 0; @@ -94,4 +140,23 @@ export class AuditService { async getLog(id: string) { return this.prisma.auditLog.findUnique({ where: { id } }); } + + /** Builds a Prisma date filter, ignoring bounds that don't parse. */ + private dateRange(from?: Date | string, to?: Date | string) { + const range: { gte?: Date; lte?: Date } = {}; + + const lower = this.toDate(from); + if (lower) range.gte = lower; + + const upper = this.toDate(to); + if (upper) range.lte = upper; + + return Object.keys(range).length ? range : undefined; + } + + private toDate(value?: Date | string): Date | undefined { + if (!value) return undefined; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; + } } diff --git a/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts new file mode 100644 index 000000000..93fa94b50 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts @@ -0,0 +1,153 @@ +import { buildSeatSummary } from './booking-sms.utils'; + +const seat = (passengerName: string, seatNumber: string, leg = 1, coachType = 'VIP Bed') => ({ + passengerName, + leg, + seat: { seatNumber, coach: { number: 'VIP-0001 (DJ)', coachType: { name: coachType } } }, +}); + +describe('buildSeatSummary', () => { + it('greets a solo traveller by name and omits the name from the seat line', () => { + const { passengerName, trainSeatLines } = buildSeatSummary([seat('Yanet', '9')], 'ONE_WAY'); + + expect(passengerName).toBe('Yanet'); + expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9'); + expect(trainSeatLines).not.toContain('Train/Seat'); + expect(trainSeatLines).not.toContain('Yanet'); + }); + + it('greets a group collectively and names each seat', () => { + const { passengerName, trainSeatLines } = buildSeatSummary( + [seat('Yanet', '4'), seat('Abebe', '6'), seat('Sara', '9'), seat('Helen', '10')], + 'ONE_WAY', + ); + + expect(passengerName).toBe('Passengers'); + expect(trainSeatLines).toBe( + [ + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 4', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6', + 'Sara, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10', + ].join('\n'), + ); + }); + + // The reported bug: the seats query had no orderBy, so Postgres heap order put the LAST + // passenger first and the SMS greeted them while texting the first passenger's phone. + it('is immune to seat rows arriving in an arbitrary order', () => { + const rows = [seat('Yanet', '9'), seat('Helen', '10'), seat('Abebe', '6'), seat('Sara', '4')]; + + const { passengerName, trainSeatLines } = buildSeatSummary(rows, 'ONE_WAY'); + + expect(passengerName).toBe('Passengers'); + // Every line pairs the right person with their own seat, regardless of input order. + expect(trainSeatLines).toBe( + [ + 'Sara, VIP-0001 (DJ) VIP Bed, seat no. 4', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10', + ].join('\n'), + ); + }); + + it('sorts seat numbers numerically, not lexicographically', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('A', '9'), seat('B', '10'), seat('C', '6'), seat('D', '4')], + 'ONE_WAY', + ); + + expect(trainSeatLines.match(/seat no\. \d+/g)).toEqual([ + 'seat no. 4', + 'seat no. 6', + 'seat no. 9', + 'seat no. 10', + ]); + }); + + it('labels round-trip legs as Outbound/Return, listing each passenger once per leg', () => { + const { passengerName, trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)], + 'ROUND_TRIP', + ); + + expect(passengerName).toBe('Passengers'); + expect(trainSeatLines).toBe( + [ + 'Outbound:', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 10', + 'Return:', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 3', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 4', + ].join('\n'), + ); + }); + + // TRANSIT leg 2 is a connecting segment of the same outbound journey — never a return. + it('labels transit legs as Leg 1/Leg 2, never Return', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)], + 'TRANSIT', + ); + + expect(trainSeatLines).toContain('Leg 1:'); + expect(trainSeatLines).toContain('Leg 2:'); + expect(trainSeatLines).not.toContain('Return'); + expect(trainSeatLines).not.toContain('Outbound'); + }); + + it('labels all four round-trip-transit legs', () => { + const { trainSeatLines } = buildSeatSummary( + [1, 2, 3, 4].map((leg) => seat('Yanet', String(leg), leg)), + 'ROUND_TRIP_TRANSIT', + ); + + expect(trainSeatLines).toBe( + [ + 'Outbound leg 1:', + 'VIP-0001 (DJ) VIP Bed, seat no. 1', + 'Outbound leg 2:', + 'VIP-0001 (DJ) VIP Bed, seat no. 2', + 'Return leg 1:', + 'VIP-0001 (DJ) VIP Bed, seat no. 3', + 'Return leg 2:', + 'VIP-0001 (DJ) VIP Bed, seat no. 4', + ].join('\n'), + ); + }); + + it('greets a solo round-trip traveller by name (same person on both legs)', () => { + const { passengerName } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Yanet', '3', 2)], + 'ROUND_TRIP', + ); + + expect(passengerName).toBe('Yanet'); + }); + + it('trims a trailing space on the coach type instead of emitting "Bed , seat"', () => { + const { trainSeatLines } = buildSeatSummary([seat('Yanet', '9', 1, 'VIP Bed ')], 'ONE_WAY'); + + expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9'); + }); + + it('falls back safely on empty or malformed input', () => { + expect(buildSeatSummary([], 'ONE_WAY')).toEqual({ passengerName: 'Passenger', trainSeatLines: '' }); + + const { passengerName, trainSeatLines } = buildSeatSummary([{ leg: 1 }], 'ONE_WAY'); + expect(passengerName).toBe('Passenger'); + expect(trainSeatLines).toBe('-, seat no. -'); + }); + + it('falls back to a generic leg heading for an unknown booking type', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Yanet', '3', 2)], + 'SOMETHING_NEW', + ); + + expect(trainSeatLines).toContain('Leg 1:'); + expect(trainSeatLines).toContain('Leg 2:'); + }); +}); diff --git a/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts new file mode 100644 index 000000000..18b1db9ac --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts @@ -0,0 +1,115 @@ +/** + * Builds the two passenger-facing values the `booking.created` SMS/email template needs: + * the `{{passengerName}}` salutation and the `{{trainSeatLines}}` block. + * + * Why this is a shared pure helper rather than inline logic: the salutation used to be + * `seats[0]?.passengerName`, and the query loading those seats had no `orderBy`. Postgres + * returns heap order for an unordered SELECT, and an UPDATE relocates a row to the end of + * the heap — so a group booking regularly greeted the LAST passenger while texting the + * first one's phone. Deriving both values from the whole seat set, sorted deterministically, + * removes the dependency on row order entirely, and keeps the formatting unit-testable + * without a Nest testing module. + * + * Group bookings send ONE SMS to Booking.contactPhone by design — BookingSeat has no + * phone/email column, so there is no per-passenger recipient. Hence 2+ passengers are + * greeted collectively and each seat line names its own occupant. + */ + +export interface SeatSummary { + /** Salutation: the traveller's name when solo, otherwise 'Passengers'. */ + passengerName: string; + /** One line per booked seat, newline-joined, with a heading per leg on multi-leg bookings. */ + trainSeatLines: string; +} + +/** + * Seat numbers are stored as strings of digits (Seat.seatNumber), so they must be compared + * numerically — a plain string compare orders '10' before '9'. Non-numeric labels sort last, + * then alphabetically among themselves. + */ +function compareSeatNumber(a: string, b: string): number { + const na = Number.parseInt(a, 10); + const nb = Number.parseInt(b, 10); + const aNum = Number.isNaN(na); + const bNum = Number.isNaN(nb); + if (aNum && bNum) return a.localeCompare(b); + if (aNum) return 1; + if (bNum) return -1; + return na - nb || a.localeCompare(b); +} + +const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim()); + +const ROUND_TRIP_TRANSIT_LEGS: Record = { + 1: 'Outbound leg 1', + 2: 'Outbound leg 2', + 3: 'Return leg 1', + 4: 'Return leg 2', +}; + +/** + * Leg numbering means different things per booking type — see the enum documented on + * TicketsController.validate. TRANSIT's leg 2 is a connecting segment of the SAME outbound + * journey, so it must never be labelled 'Return'. + */ +function legLabel(bookingType: string | undefined, leg: number): string { + switch (bookingType) { + case 'ROUND_TRIP': + return leg === 1 ? 'Outbound' : leg === 2 ? 'Return' : `Leg ${leg}`; + case 'TRANSIT': + return `Leg ${leg}`; + case 'ROUND_TRIP_TRANSIT': + return ROUND_TRIP_TRANSIT_LEGS[leg] ?? `Leg ${leg}`; + default: + // Unknown or newly added booking type — degrade to a generic heading rather than guessing. + return `Leg ${leg}`; + } +} + +export function buildSeatSummary(seats: any[], bookingType?: string): SeatSummary { + const rows = [...(seats ?? [])].sort( + (a, b) => + (a?.leg ?? 1) - (b?.leg ?? 1) || + str(a?.seat?.coach?.number).localeCompare(str(b?.seat?.coach?.number)) || + compareSeatNumber(str(a?.seat?.seatNumber), str(b?.seat?.seatNumber)), + ); + + // Distinct travellers. A round-trip/transit booking has one row per passenger PER LEG, so + // the same name legitimately repeats — count people, not rows. + const names: string[] = []; + for (const row of rows) { + const name = str(row?.passengerName); + if (name && !names.includes(name)) names.push(name); + } + const isGroup = names.length > 1; + + const line = (row: any): string => { + const coach = str(row?.seat?.coach?.number) || '-'; + const coachType = str(row?.seat?.coach?.coachType?.name); + const seatNo = str(row?.seat?.seatNumber) || '-'; + // Trim each part before joining: the coach-type name carries a trailing space in some + // records, which a `.replace(/ +/g, ' ')` collapse cannot remove (it shrinks runs of + // spaces but leaves a single one), and it surfaced as 'VIP Bed , seat no. 9'. + const where = [coach, coachType].filter(Boolean).join(' '); + const who = isGroup ? `${str(row?.passengerName) || 'Passenger'}, ` : ''; + return `${who}${where}, seat no. ${seatNo}`; + }; + + const legs = [...new Set(rows.map((row) => row?.leg ?? 1))]; + const trainSeatLines = + legs.length > 1 + ? legs + .map((leg) => + [ + `${legLabel(bookingType, leg)}:`, + ...rows.filter((row) => (row?.leg ?? 1) === leg).map(line), + ].join('\n'), + ) + .join('\n') + : rows.map(line).join('\n'); + + return { + passengerName: isGroup ? 'Passengers' : (names[0] || 'Passenger'), + trainSeatLines, + }; +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 3a1e94760..a56e5cf86 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AuditService } from '../../common/audit.service'; import { PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; @ApiTags('Audit') @Controller('audit') @@ -16,24 +17,52 @@ export class AuditController { summary: 'Get audit logs', description: 'Retrieve system audit logs with optional filtering', }) - @ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' }) - @ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' }) - @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' }) + @ApiQuery({ name: 'search', required: false, description: 'Match entity ID, actor ID, actor name, or actor phone' }) + @ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, BOARD, WAIVE, ...)' }) + @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, Ticket, ...)' }) + @ApiQuery({ name: 'iamUserId', required: false, description: 'Exact IAM user id — everything one staff member did' }) + @ApiQuery({ name: 'from', required: false, description: 'Earliest createdAt (ISO 8601), inclusive' }) + @ApiQuery({ name: 'to', required: false, description: 'Latest createdAt (ISO 8601), inclusive' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Page size (default 50, max 200)' }) + @ApiQuery({ name: 'offset', required: false, type: Number, description: 'Rows to skip (default 0)' }) async getLogs( @Query('search') search?: string, @Query('action') action?: string, @Query('entityType') entityType?: string, + @Query('iamUserId') iamUserId?: string, + @Query('from') from?: string, + @Query('to') to?: string, + // The service has always implemented paging; the controller simply never forwarded it, which + // pinned the backoffice page and its CSV export to the 50 newest rows. + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, ) { - const filters = { + const result = await this.auditService.getLogs({ search: search || undefined, action: action || undefined, entityType: entityType || undefined, - }; - - const result = await this.auditService.getLogs(filters); + iamUserId: iamUserId || undefined, + from: from || undefined, + to: to || undefined, + limit, + offset, + }); return { items: result.data, total: result.total, limit: result.limit, offset: result.offset }; } + /** + * The vocabularies the writers use, so the backoffice filters stay in step with what the API + * actually records instead of drifting behind a hand-maintained list. + */ + @Get('vocabulary') + @ApiOperation({ summary: 'Audit action and entity-type vocabularies' }) + getVocabulary() { + return { + actions: Object.values(AUDIT_ACTIONS), + entityTypes: Object.values(AUDIT_ENTITIES), + }; + } + @Get('logs/:id') @ApiOperation({ summary: 'Get audit log by ID' }) async getLog(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts new file mode 100644 index 000000000..f1acb6cd2 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.spec.ts @@ -0,0 +1,130 @@ +import { BadRequestException } from '@nestjs/common'; +import { IdDocumentType } from '@prisma/client'; +import { + assertIdentitiesNotAlreadyBooked, + resolveIdentityRef, +} from './booking-identity.util'; +import { PrismaService } from '../../common/prisma.service'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const SCHEDULE = 'schedule-1'; +const RETURN_SCHEDULE = 'schedule-2'; + +const makePrisma = (clash: any = null) => + ({ bookingSeat: { findFirst: jest.fn().mockResolvedValue(clash) } }) as unknown as PrismaService; + +const traveller = (passengerName: string, identityRef: string | null) => ({ + passengerName, + identityRef, +}); + +// ── resolveIdentityRef ─────────────────────────────────────────────────────── + +describe('resolveIdentityRef', () => { + it('uses the Fayda sub for national-ID travellers', () => { + expect( + resolveIdentityRef({ + idDocumentType: IdDocumentType.NATIONAL_ID, + faydaSub: 'psut-abc', + passportNumber: 'P1234567', + }), + ).toBe('psut-abc'); + }); + + it('uses the passport number for passport travellers, normalised to upper case', () => { + expect( + resolveIdentityRef({ + idDocumentType: IdDocumentType.PASSPORT, + faydaSub: 'psut-abc', + passportNumber: ' p1234567 ', + }), + ).toBe('P1234567'); + }); + + it('returns null when there is nothing to key on — children and Fayda-disabled bookings', () => { + expect(resolveIdentityRef({ idDocumentType: IdDocumentType.NATIONAL_ID })).toBeNull(); + expect( + resolveIdentityRef({ idDocumentType: IdDocumentType.NATIONAL_ID, faydaSub: ' ' }), + ).toBeNull(); + expect(resolveIdentityRef({ idDocumentType: IdDocumentType.PASSPORT })).toBeNull(); + }); +}); + +// ── assertIdentitiesNotAlreadyBooked ───────────────────────────────────────── + +describe('assertIdentitiesNotAlreadyBooked', () => { + it('rejects the same identity used twice inside one payload', async () => { + const prisma = makePrisma(); + await expect( + assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Abebe Kebede', 'psut-abc'), traveller('Sara Ali', 'psut-abc')], + [SCHEDULE], + ), + ).rejects.toThrow(BadRequestException); + // Rejected before touching the database. + expect(prisma.bookingSeat.findFirst).not.toHaveBeenCalled(); + }); + + it('ignores passengers with no identity — two children never collide with each other', async () => { + const prisma = makePrisma(); + await expect( + assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Child One', null), traveller('Child Two', null)], + [SCHEDULE], + ), + ).resolves.toBeUndefined(); + expect(prisma.bookingSeat.findFirst).not.toHaveBeenCalled(); + }); + + it('queries every leg of the booking, de-duplicated, for active bookings only', async () => { + const prisma = makePrisma(); + await assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Abebe Kebede', 'psut-abc')], + [SCHEDULE, RETURN_SCHEDULE, SCHEDULE, null, undefined], + ); + + const { where } = (prisma.bookingSeat.findFirst as jest.Mock).mock.calls[0][0]; + expect(where.scheduleId).toEqual({ in: [SCHEDULE, RETURN_SCHEDULE] }); + expect(where.idDocumentNumber).toEqual({ in: ['psut-abc'] }); + expect(where.booking.status.in).toEqual(['DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'BOARDED']); + }); + + it('rejects an identity that already holds a ticket on the departure', async () => { + const prisma = makePrisma({ + idDocumentNumber: 'psut-abc', + passengerName: 'Abebe K.', + booking: { bookingRef: 'ABCDEF', status: 'CONFIRMED' }, + }); + + await expect( + assertIdentitiesNotAlreadyBooked(prisma, [traveller('Abebe Kebede', 'psut-abc')], [SCHEDULE]), + ).rejects.toThrow(/Abebe Kebede already has a ticket on this train \(booking ABCDEF\)/); + }); + + it('points an unpaid clash at the booking the traveller still has to settle', async () => { + const prisma = makePrisma({ + idDocumentNumber: 'psut-abc', + passengerName: 'Abebe K.', + booking: { bookingRef: 'ABCDEF', status: 'PENDING_PAYMENT' }, + }); + + await expect( + assertIdentitiesNotAlreadyBooked(prisma, [traveller('Abebe Kebede', 'psut-abc')], [SCHEDULE]), + ).rejects.toThrow(/already has an unpaid booking \(ABCDEF\)/); + }); + + it('allows the booking when nothing active matches — a cancelled ticket frees the identity', async () => { + const prisma = makePrisma(null); + await expect( + assertIdentitiesNotAlreadyBooked( + prisma, + [traveller('Abebe Kebede', 'psut-abc'), traveller('Sara Ali', 'P7654321')], + [SCHEDULE], + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts new file mode 100644 index 000000000..cd5385765 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts @@ -0,0 +1,96 @@ +import { BadRequestException } from '@nestjs/common'; +import { BookingStatus, IdDocumentType } from '@prisma/client'; +import { PrismaService } from '../../common/prisma.service'; + +/** + * Booking states that still hold a traveller's place on a departure. CANCELLED, REFUNDED and + * NO_SHOW are deliberately excluded: cancelling a ticket must immediately free the identity so + * the same person can book that train again. PENDING_PAYMENT counts — otherwise the whole check + * is bypassable by simply never finishing the first payment. + */ +const ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ + BookingStatus.DRAFT, + BookingStatus.PENDING_PAYMENT, + BookingStatus.CONFIRMED, + BookingStatus.BOARDED, +]; + +/** + * The single value that identifies a human across bookings: the Fayda subject identifier (PSUT) + * for Ethiopians, the passport number for everyone else. It is written to + * `BookingSeat.idDocumentNumber` — an existing column, so no migration — and compared there. + * + * Returns null when there is nothing to key on: children under 5 have no Fayda, and neither does + * a booking made while the Fayda integration is switched off. Those passengers are simply not + * deduplicated rather than being blocked. + * + * Both inputs come from the client, so this stops honest misuse of the booking form, not a + * hand-crafted POST. Binding the sub to the server-side verification session is the follow-up + * that would make it tamper-proof. + */ +export function resolveIdentityRef(passenger: { + idDocumentType?: IdDocumentType | null; + faydaSub?: string | null; + passportNumber?: string | null; +}): string | null { + if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + // Hand-typed, so normalise case — "p1234567" and "P1234567" are the same document. + const passport = passenger.passportNumber?.trim().toUpperCase(); + return passport || null; + } + const sub = passenger.faydaSub?.trim(); + return sub || null; +} + +/** + * Rejects a booking when one identity would occupy more than one seat on the same departure — + * either twice within this payload, or once here and once on an existing active booking. + * + * Keyed on `BookingSeat.scheduleId`, which is per leg, so round-trip outbound/return and transit + * leg-1/leg-2 are naturally treated as separate departures and never collide with each other. + */ +export async function assertIdentitiesNotAlreadyBooked( + prisma: PrismaService, + passengers: Array<{ passengerName: string; identityRef: string | null }>, + scheduleIds: Array, +): Promise { + const nameByIdentity = new Map(); + for (const passenger of passengers) { + if (!passenger.identityRef) continue; + const alreadyUsedBy = nameByIdentity.get(passenger.identityRef); + if (alreadyUsedBy !== undefined) { + throw new BadRequestException( + `${passenger.passengerName} and ${alreadyUsedBy} were verified with the same identity. ` + + `Each traveller must be verified with their own Fayda or passport.`, + ); + } + nameByIdentity.set(passenger.identityRef, passenger.passengerName); + } + + const identityRefs = [...nameByIdentity.keys()]; + const targetScheduleIds = [...new Set(scheduleIds.filter((id): id is string => !!id))]; + if (!identityRefs.length || !targetScheduleIds.length) return; + + const clash = await prisma.bookingSeat.findFirst({ + where: { + scheduleId: { in: targetScheduleIds }, + idDocumentNumber: { in: identityRefs }, + booking: { status: { in: ACTIVE_BOOKING_STATUSES } }, + }, + select: { + idDocumentNumber: true, + passengerName: true, + booking: { select: { bookingRef: true, status: true } }, + }, + }); + if (!clash) return; + + const traveller = nameByIdentity.get(clash.idDocumentNumber!) ?? clash.passengerName; + throw new BadRequestException( + clash.booking.status === BookingStatus.PENDING_PAYMENT + ? `${traveller} already has an unpaid booking (${clash.booking.bookingRef}) on this train. ` + + `Complete or cancel that booking before making a new one.` + : `${traveller} already has a ticket on this train (booking ${clash.booking.bookingRef}). ` + + `Each traveller may hold only one ticket per departure.`, + ); +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 42d4af0d9..454339516 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -18,6 +18,7 @@ export class PassengerInputDto { dateOfBirth: Date; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; + @ApiPropertyOptional({ example: '8267a1f4-...', description: 'Fayda subject identifier (PSUT) from POST /fayda/verification/complete. Stored on the booking seat and compared across bookings so one Fayda identity cannot hold two seats on the same departure.' }) @IsOptional() @IsString() faydaSub?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; @@ -67,11 +68,19 @@ export class RoundTripPassengerDto { description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; - - @ApiPropertyOptional({ - example: 'P1234567', - description: 'Passport number for non-Ethiopian passengers (no verification)' - }) + + @ApiPropertyOptional({ + example: '8267a1f4-...', + description: + 'Fayda subject identifier (PSUT) from POST /fayda/verification/complete. Stored on the booking seat ' + + 'and compared across bookings so one Fayda identity cannot hold two seats on the same departure.' + }) + @IsOptional() @IsString() faydaSub?: string; + + @ApiPropertyOptional({ + example: 'P1234567', + description: 'Passport number for non-Ethiopian passengers (no verification)' + }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 6330c8cc6..fc958be70 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -6,6 +6,7 @@ import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; +import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { CurrencyService } from '../currency/currency.service'; @@ -870,6 +871,11 @@ export class BookingsService { this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); + + // One traveller, one seat per departure — checked before any fare/hold work so a rejected + // booking leaves nothing behind. + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [dto.scheduleId]); + const fareCalculation = dto.packageId && dto.priceTierId ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); @@ -997,6 +1003,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1082,6 +1089,11 @@ export class BookingsService { ]); const { adultCount, childCount } = this.countPassengers(passengersData); + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.returnScheduleId, + ]); + // Package bookings use fixed tier price split equally across both legs let outboundFare: Awaited>; let returnFare: Awaited>; @@ -1220,6 +1232,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1235,6 +1248,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1340,6 +1354,11 @@ export class BookingsService { ]); const { adultCount, childCount } = this.countPassengers(passengersData); + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + ]); + const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; const [leg1Fare, leg2Fare] = await Promise.all([ this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount), @@ -1425,6 +1444,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1440,6 +1460,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1563,6 +1584,14 @@ export class BookingsService { this.resolveIamContact(dto.passengerId), ]); const { adultCount, childCount } = this.countPassengers(passengersData); + + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + dto.returnScheduleId, + dto.returnLeg2ScheduleId, + ]); + const nat = passengersData[0]?.nationality; const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; @@ -1626,6 +1655,7 @@ export class BookingsService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1727,7 +1757,7 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } return processedPassengers; } @@ -1764,6 +1794,7 @@ export class BookingsService { verifaydaVerified, verifaydaData, nationality, + identityRef: resolveIdentityRef(passenger), // Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses // outboundSeatId/returnSeatId. Accept either form so both DTOs work. outboundSeatId: passenger.outboundSeatId ?? passenger.seatId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index ac60b8480..7065b6183 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -25,10 +25,19 @@ export class GuestPassengerDto { @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; - @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored)' }) + @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored)' }) @IsOptional() @IsString() idDocumentNumber?: string; - @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' }) + @ApiPropertyOptional({ + example: '8267a1f4-...', + description: + 'Fayda subject identifier (PSUT) returned by POST /fayda/verification/complete for this traveller. ' + + 'Stored on the booking seat and compared across bookings so one Fayda identity cannot hold two ' + + 'seats on the same departure. Omit for children under 5 and non-Ethiopians (the passport number is used instead).', + }) + @IsOptional() @IsString() faydaSub?: string; + + @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopians' }) @IsOptional() @IsString() passportNumber?: string; @ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 4b3a9a53d..5484aa658 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -12,6 +12,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { PaymentsService } from '../payments/payments.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto'; +import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; import { JourneyDirection } from '../seats/seats.dto'; import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; @@ -226,9 +227,14 @@ export class GuestBookingService { verifaydaVerified, verifaydaData, nationality, + identityRef: resolveIdentityRef(passenger), }); } + // One traveller, one seat per departure — checked before any fare/hold work so a rejected + // booking leaves nothing behind. + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [dto.scheduleId]); + // Calculate fare — package bookings use the fixed tier price, bypassing the fare engine const isPackageOneway = !!dto.packageId && !!dto.priceTierId; let baseFareMinor: number; @@ -393,6 +399,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -773,9 +780,14 @@ export class GuestBookingService { nationality = nationality || 'Other'; } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.returnScheduleId, + ]); + // Calculate fares for both legs — package bookings use the fixed tier price split across legs const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId; @@ -936,6 +948,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -951,6 +964,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1073,9 +1087,14 @@ export class GuestBookingService { } else { nationality = nationality || 'Other'; } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + ]); + const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; const primaryNationality = passengersData[0]?.nationality; const paidChildrenCount = Math.max(0, childCount - 1); @@ -1146,6 +1165,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1161,6 +1181,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, @@ -1283,9 +1304,16 @@ export class GuestBookingService { } else { nationality = nationality || 'Other'; } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality, identityRef: resolveIdentityRef(passenger) }); } + await assertIdentitiesNotAlreadyBooked(this.prisma, passengersData, [ + dto.scheduleId, + dto.leg2ScheduleId, + dto.returnScheduleId, + dto.returnLeg2ScheduleId, + ]); + const nat = passengersData[0]?.nationality; const paidChildren = Math.max(0, childCount - 1); const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; @@ -1326,6 +1354,7 @@ export class GuestBookingService { dateOfBirth: p.dateOfBirth, passengerCategory: p.category, idDocumentType: p.idDocumentType, + idDocumentNumber: p.identityRef, passportNumber: p.passportNumber, passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 648deb77c..6d1112175 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; @Injectable() export class CurrenciesService { @@ -59,7 +60,17 @@ export class CurrenciesService { }, }); - await this.auditService.log({ action: 'CREATE', entityType: 'Currency', entityId: rate.id, newData: { code, exchangeRate } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Currency, + entityId: rate.id, + newData: { + fromCurrency: rate.fromCurrency, + toCurrency: rate.toCurrency, + rate: Number(rate.rate), + source: rate.source, + }, + }); return { id: rate.id, code: rate.toCurrency, @@ -95,7 +106,22 @@ export class CurrenciesService { 'MANUAL', ); - await this.auditService.log({ action: 'UPDATE', entityType: 'Currency', entityId: updated.id, newData: { exchangeRate: Number(updated.rate) } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Currency, + entityId: updated.id, + oldData: { + fromCurrency: existing.fromCurrency, + toCurrency: existing.toCurrency, + rate: Number(existing.rate), + }, + newData: { + fromCurrency: updated.fromCurrency, + toCurrency: updated.toCurrency, + rate: Number(updated.rate), + source: updated.source, + }, + }); return { id: updated.id, code: updated.toCurrency, @@ -112,6 +138,8 @@ export class CurrenciesService { async syncExchangeRates() { // Placeholder: in production this would fetch from an external FX API. // For now, return the current rates as-is. + // Deliberately unaudited: this writes nothing today. Instrument it in the same commit that + // gives it a real external fetch, otherwise the trail claims a change that never happened. const currencies = await this.getAllCurrencies(); return { synced: true, rates: currencies }; } @@ -129,7 +157,16 @@ export class CurrenciesService { await this.prisma.currencyExchangeRate.deleteMany({ where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency }, }); - await this.auditService.log({ action: 'DELETE', entityType: 'Currency', entityId: id, oldData: { toCurrency: existing.toCurrency } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Currency, + entityId: id, + oldData: { + fromCurrency: existing.fromCurrency, + toCurrency: existing.toCurrency, + rate: Number(existing.rate), + }, + }); return { message: 'Currency deleted successfully' }; } diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts index eb7bd33b6..6f74ab412 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Query, SetMetadata, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -16,6 +16,27 @@ export class DashboardController { @ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' }) getBackofficeStats() { return this.service.getBackofficeStats(); } + // Two segments, so the single-segment `@Get(':passengerId')` below cannot swallow it + // however the routes are ordered. Staff-guarded like backoffice-stats, not JwtGuard. + @Get('analytics/bookings') + @PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Booking analytics for the dashboard charts', + description: + 'Revenue trend, daily confirmed bookings, booking status distribution and payment-method split over the ' + + 'last `days` days (default 30), bucketed by booking creation date.\n\n' + + 'Revenue and the daily count cover CONFIRMED and BOARDED bookings; the status and payment-method ' + + 'breakdowns cover every booking in range — the same asymmetry the /reports/overall page applies, kept so ' + + 'the two agree.\n\n' + + 'Revenue is returned per currency and unconverted; the caller applies its own exchange rates. These ' + + 'figures answer "what was booked" and will not match the Revenue Breakdown card, which requires a ' + + 'SUCCEEDED payment intent and answers "what was collected".', + }) + getBookingAnalytics(@Query('days') days?: string) { + return this.service.getBookingAnalytics(days ? Number(days) : undefined); + } + @Get(':passengerId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index f7e015e11..79891a023 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -3,6 +3,11 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; +// ── Booking analytics (backoffice dashboard charts) ────────────────────────── +const MS_PER_DAY_ANALYTICS = 24 * 60 * 60 * 1000; +const ANALYTICS_DEFAULT_DAYS = 30; +const ANALYTICS_MAX_DAYS = 365; + @Injectable() export class DashboardService { constructor( @@ -63,6 +68,109 @@ export class DashboardService { }; } + /** + * Booking analytics for the backoffice dashboard charts — revenue trend, daily + * confirmed bookings, status distribution and payment-method split. + * + * Ported from the client-side computation on `/reports/overall`, which pulled up to + * 5000 bookings into the browser and grouped them there. The dashboard is the landing + * page and refetches on an interval, so the grouping happens here instead. + * + * Two asymmetries are inherited from that report on purpose, so the dashboard and the + * report show the same figures: + * - Revenue and the daily count use CONFIRMED and BOARDED only; the status and + * payment-method breakdowns use every booking in range. + * - Everything buckets on `createdAt` — when the booking was made, not when the + * train departs. + * + * Revenue here will NOT equal the dashboard's Revenue Breakdown card, which + * additionally requires a SUCCEEDED PaymentIntent and prefers the display amounts + * (see getBackofficeStats). Different question, deliberately not reconciled: this is + * "what was booked", that is "what was collected". + */ + async getBookingAnalytics(daysRaw?: number) { + const days = Math.min( + Math.max(Math.trunc(daysRaw || ANALYTICS_DEFAULT_DAYS), 1), + ANALYTICS_MAX_DAYS, + ); + const to = new Date(); + const from = new Date(to.getTime() - days * MS_PER_DAY_ANALYTICS); + + const bookings = await this.prisma.booking.findMany({ + where: { createdAt: { gte: from, lte: to } }, + select: { + createdAt: true, + status: true, + totalMinor: true, + currency: true, + paymentIntent: { select: { method: true } }, + }, + }); + + const isConfirmed = (status: string) => status === 'CONFIRMED' || status === 'BOARDED'; + + // Day buckets keyed on the UTC calendar date, so the axis and the bars derive from + // one value and cannot disagree. + const byDayMap = new Map< + string, + { date: string; bookings: number; revenueByCurrency: Map } + >(); + const statusCounts = new Map(); + const methodCounts = new Map(); + + for (const booking of bookings) { + // Status and payment method count every booking in range. + const status = booking.status ?? 'UNKNOWN'; + statusCounts.set(status, (statusCounts.get(status) ?? 0) + 1); + + const method = booking.paymentIntent?.method ?? 'UNKNOWN'; + methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); + + // Revenue and the daily count are confirmed travel only. + if (!isConfirmed(booking.status)) continue; + + const date = booking.createdAt.toISOString().slice(0, 10); + const bucket = + byDayMap.get(date) ?? { date, bookings: 0, revenueByCurrency: new Map() }; + bucket.bookings += 1; + + const currency = booking.currency ?? 'ETB'; + bucket.revenueByCurrency.set( + currency, + (bucket.revenueByCurrency.get(currency) ?? 0) + (booking.totalMinor ?? 0), + ); + byDayMap.set(date, bucket); + } + + const byDay = [...byDayMap.values()] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((bucket) => ({ + date: bucket.date, + bookings: bucket.bookings, + revenueByCurrency: [...bucket.revenueByCurrency.entries()].map( + ([currency, totalMinor]) => ({ currency, totalMinor }), + ), + })); + + const rank = (rows: T[]) => + rows.sort((a, b) => b.count - a.count); + + return { + window: { from, to, days }, + totals: { + bookings: bookings.length, + confirmedBookings: bookings.filter((b) => isConfirmed(b.status)).length, + }, + byDay, + statusDistribution: rank( + [...statusCounts.entries()].map(([status, count]) => ({ status, count })), + ), + paymentMethods: rank( + [...methodCounts.entries()].map(([method, count]) => ({ method, count })), + ), + }; + } + async getHomeDashboard(passengerId: string) { const now = new Date(); const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-audit.spec.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-audit.spec.ts new file mode 100644 index 000000000..58bc5ffed --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-audit.spec.ts @@ -0,0 +1,229 @@ +import { ExcessBaggageService } from './excess-baggage.service'; + +/** + * Excess baggage settles through two paths — the in-app one (`markPaid`, reached from + * `initiatePayment`/`confirmOtp`) and the payment webhook. The in-app path used to write no + * audit row at all, so whether a settlement was recorded depended on which route reached it + * first. Both now claim the transition conditionally, so it lands exactly once either way. + */ +describe('ExcessBaggageService — audit', () => { + const CHARGE_ID = 'ebc-1'; + const BOOKING_ID = 'booking-1'; + + let prisma: Record; + let audit: { log: jest.Mock }; + let service: ExcessBaggageService; + + const build = (charge: Record = {}) => { + const row = { + id: CHARGE_ID, + bookingId: BOOKING_ID, + agentId: 'iam-agent-1', + excessWeightKg: 8, + feePerKgMinor: 5000, + totalMinor: 40000, + currency: 'ETB', + status: 'PENDING', + paymentToken: 'tok-live-secret', + contactPhone: '+251911223344', + contactEmail: 'passenger@example.com', + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + booking: { bookingRef: 'EDR-0001', passengerId: 'p-1' }, + ...charge, + }; + + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(row), + // Default: this caller wins the race and flips the row. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + update: jest.fn().mockResolvedValue({ ...row, status: 'WAIVED' }), + delete: jest.fn().mockResolvedValue(row), + create: jest.fn().mockResolvedValue(row), + }, + baggageAllowance: { + findFirst: jest.fn().mockResolvedValue(null), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(), + update: jest.fn(), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + audit = { log: jest.fn().mockResolvedValue(undefined) }; + + // Constructor order: prisma, audit, currency, paymentClient, notifications, sms, email. + service = new ExcessBaggageService( + prisma as any, + audit as any, + {} as any, + {} as any, + {} as any, + { sendSms: jest.fn() } as any, + { sendEmail: jest.fn() } as any, + ); + return row; + }; + + const rows = () => audit.log.mock.calls.map((c) => c[0]); + const byAction = (action: string) => rows().filter((r) => r.action === action); + + describe('markPaid', () => { + it('writes one PAY row when it actually flips the charge', async () => { + build(); + await service.markPaid(CHARGE_ID, 'TXN-77'); + + expect(byAction('PAY')).toHaveLength(1); + expect(byAction('PAY')[0]).toMatchObject({ + entityType: 'ExcessBaggageCharge', + entityId: CHARGE_ID, + oldData: { status: 'PENDING' }, + }); + expect(byAction('PAY')[0].newData).toMatchObject({ + status: 'PAID', + bookingId: BOOKING_ID, + totalMinor: 40000, + providerTxnId: 'TXN-77', + }); + }); + + it('writes nothing when the webhook already claimed the transition', async () => { + build(); + // count: 0 means another caller flipped the row first and already logged it. + prisma.excessBaggageCharge.updateMany.mockResolvedValue({ count: 0 }); + await service.markPaid(CHARGE_ID, 'TXN-77'); + + expect(byAction('PAY')).toHaveLength(0); + }); + + it('is a no-op on a charge already read as PAID', async () => { + build({ status: 'PAID' }); + await service.markPaid(CHARGE_ID); + + expect(audit.log).not.toHaveBeenCalled(); + expect(prisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled(); + }); + + it('never puts the payment token on the row', async () => { + build(); + await service.markPaid(CHARGE_ID, 'TXN-77'); + expect(JSON.stringify(rows())).not.toContain('tok-live-secret'); + }); + }); + + describe('waiveCharge', () => { + it('records WAIVE with the status it moved from', async () => { + build(); + await service.waiveCharge(CHARGE_ID, { waivedBy: 'Supervisor Bob', waivedReason: 'goodwill' }); + + expect(byAction('WAIVE')).toHaveLength(1); + expect(byAction('WAIVE')[0]).toMatchObject({ + entityType: 'ExcessBaggageCharge', + entityId: CHARGE_ID, + oldData: { status: 'PENDING' }, + }); + }); + + it('does not let the request body become the audit actor', async () => { + build(); + await service.waiveCharge(CHARGE_ID, { waivedBy: 'somebody-else', waivedReason: 'x' }); + + // `waivedBy` is descriptive context only; the actor comes from the session inside + // AuditService, so no call site sets `userId`. + const row = byAction('WAIVE')[0]; + expect(row.userId).toBeUndefined(); + expect(row.newData.waivedBy).toBe('somebody-else'); + }); + + it('records nothing when the waiver is refused', async () => { + build({ status: 'PAID' }); + await expect( + service.waiveCharge(CHARGE_ID, { waivedBy: 'Bob' }), + ).rejects.toThrow(); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); + + describe('deleteCharge', () => { + it('records the hard delete of a money record', async () => { + build(); + await service.deleteCharge(CHARGE_ID); + + expect(byAction('DELETE')).toHaveLength(1); + expect(byAction('DELETE')[0].oldData).toMatchObject({ + bookingId: BOOKING_ID, + totalMinor: 40000, + status: 'PENDING', + }); + }); + }); + + describe('baggage allowances (Tariff Rates)', () => { + it('records a CREATE when no allowance existed', async () => { + build(); + prisma.baggageAllowance.create.mockResolvedValue({ + id: 'ba-1', + seatClassId: 'sc-1', + maxWeightKg: 20, + maxPiecesCount: 2, + excessFeePerKg: 5000, + }); + + await service.upsertAllowance({ seatClassId: 'sc-1', maxWeightKg: 20, excessFeePerKg: 5000 }); + + expect(byAction('CREATE')).toHaveLength(1); + expect(byAction('CREATE')[0]).toMatchObject({ entityType: 'BaggageAllowance', entityId: 'ba-1' }); + }); + + it('records an UPDATE with the previous fee when one already existed', async () => { + build(); + prisma.baggageAllowance.findFirst.mockResolvedValue({ + id: 'ba-1', + seatClassId: 'sc-1', + maxWeightKg: 20, + maxPiecesCount: 2, + excessFeePerKg: 5000, + }); + prisma.baggageAllowance.update.mockResolvedValue({ + id: 'ba-1', + seatClassId: 'sc-1', + maxWeightKg: 25, + maxPiecesCount: 2, + excessFeePerKg: 7500, + }); + + await service.upsertAllowance({ seatClassId: 'sc-1', maxWeightKg: 25, excessFeePerKg: 7500 }); + + const row = byAction('UPDATE')[0]; + expect(row).toMatchObject({ entityType: 'BaggageAllowance', entityId: 'ba-1' }); + expect(row.oldData).toMatchObject({ excessFeePerKg: 5000 }); + expect(row.newData).toMatchObject({ excessFeePerKg: 7500 }); + }); + + it('records the deletion of an allowance', async () => { + build(); + prisma.baggageAllowance.findUnique.mockResolvedValue({ + id: 'ba-1', + seatClassId: 'sc-1', + maxWeightKg: 20, + maxPiecesCount: 2, + excessFeePerKg: 5000, + }); + + await service.deleteAllowance('ba-1'); + + expect(byAction('DELETE')).toHaveLength(1); + expect(byAction('DELETE')[0].oldData).toMatchObject({ excessFeePerKg: 5000 }); + }); + }); + + describe('reads', () => { + it('writes nothing when listing allowances', async () => { + build(); + prisma.baggageAllowance.findMany = jest.fn().mockResolvedValue([]); + prisma.seatClass = { findMany: jest.fn().mockResolvedValue([]) }; + + await service.getAllowances(); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts new file mode 100644 index 000000000..1dbbdc86a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage-currency.spec.ts @@ -0,0 +1,455 @@ +import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodType } from '@prisma/client'; +import { ExcessBaggageService } from './excess-baggage.service'; +import { CurrencyService } from '../currency/currency.service'; + +/** + * An excess baggage charge is always booked in ETB, but each payment method settles in its own + * currency and the payment microservice forwards whatever it is given straight to the gateway. + * These cover the ETB→settlement conversion that has to happen here — and that the quote shown to + * the payer is computed from the same code path as the amount actually charged. + */ +describe('ExcessBaggageService — charge currency', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + // 350.00 ETB owed for 7kg at 50.00 ETB/kg. + const charge = { + id: CHARGE_ID, + totalMinor: 35_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + const build = (rate?: { rate: number }) => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + paymentMethod: { findUnique: jest.fn() }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue(rate ?? null), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + status: 'REQUIRES_ACTION', + clientAction: { type: 'REDIRECT', url: 'https://gateway.test/pay' }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest.fn(), + confirmOtp: jest.fn(), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, // auditService + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, // notifications + {} as any, // smsClient + {} as any, // emailClient + ); + }; + + const withMethod = (type: string, currency: string) => + prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency }); + + it('charges an Ethiopian wallet in ETB, unconverted', async () => { + build(); + withMethod(PaymentMethodType.TELEBIRR, 'ETB'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled(); + }); + + it('converts to DJF for Waafi and rounds to whole francs', async () => { + build({ rate: 3.2 }); // 1 ETB = 3.2 DJF + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI); + + // 350.00 ETB × 3.2 = 1120 DJF — DJF has no minor unit. + expect(quote).toMatchObject({ currency: 'DJF', amount: 1120 }); + expect(Number.isInteger(quote.amount)).toBe(true); + }); + + it('sends the provider the converted amount and its own currency, not the stored ETB total', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + platform: 'web', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + referenceType: 'EXCESS_BAGGAGE', + referenceId: CHARGE_ID, + amountMinor: 1120, + currency: 'DJF', + provider: PaymentMethodType.WAAFI, + }), + ); + }); + + it('quotes and charges the same figure for the same method', async () => { + build({ rate: 0.0175 }); // 1 ETB = 0.0175 USD + withMethod(PaymentMethodType.CARD, 'USD'); + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CARD); + await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CARD, + platform: 'web', + } as any); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(quote.amount).toBe(sent.amountMinor); + expect(quote.currency).toBe(sent.currency); + expect(sent.amountMinor).toBe(6.13); // 350 × 0.0175 = 6.125 → 6.13 USD + }); + + it('forces ETB for CBE_BILL, which settles ETB only', async () => { + build({ rate: 3.2 }); + withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win + + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL); + + expect(quote).toMatchObject({ currency: 'ETB', amount: 350 }); + }); + + it('refuses WALLET, which has no excess-baggage path', async () => { + build(); + + await expect( + service.quoteAmount(TOKEN, PaymentMethodType.WALLET), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WALLET, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('fails closed when no exchange rate is configured — never charges at parity', async () => { + build(); // no rate rows at all + withMethod(PaymentMethodType.WAAFI, 'DJF'); + + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.WAAFI, + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); +}); + +/** + * CAC Bank is an OTP debit: the bank SMSes a one-time password to a mobile number it must be given + * at initiate, and the payment only settles once that password is submitted back. + */ +describe('ExcessBaggageService — CAC Bank OTP debit', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + + const charge = { + id: CHARGE_ID, + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + booking: { bookingRef: 'BAG-001', scheduleId: 'sched-1' }, + }; + + let prisma: Record; + let paymentClient: { + initiate: jest.Mock; + getIntentByReference: jest.Mock; + confirmOtp: jest.Mock; + }; + let service: ExcessBaggageService; + + beforeEach(() => { + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CAC_BANK', currency: 'DJF' }), + }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue({ rate: 3.25 }), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'COLLECT_OTP', + message: 'Enter the OTP sent to 77****56', + }, + merchantOrderId: 'MO-1', + }), + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'SUCCEEDED', + providerTxnId: 'CAC-TXN-9', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + it('rejects initiate without a payer mobile — the bank has nowhere to send the OTP', async () => { + await expect( + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('forwards the payer mobile and returns the OTP client action', async () => { + const result = await service.initiatePayment(TOKEN, { + method: PaymentMethodType.CAC_BANK, + platform: 'web', + payerAccount: ' 77123456 ', + } as any); + + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + payerAccount: '77123456', // trimmed + currency: 'DJF', + amountMinor: 813, // 250.00 ETB × 3.25, whole francs + }), + ); + expect(result.clientAction).toMatchObject({ type: 'COLLECT_OTP' }); + }); + + it('submits the OTP against the charge’s active intent and marks it paid', async () => { + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(paymentClient.getIntentByReference).toHaveBeenCalledWith( + 'EXCESS_BAGGAGE', + CHARGE_ID, + ); + expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530'); + expect(prisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: CHARGE_ID }), + data: expect.objectContaining({ status: 'PAID' }), + }), + ); + expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false }); + }); + + it('leaves the charge unpaid when the OTP does not settle', async () => { + paymentClient.confirmOtp.mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + }); + + const result = await service.confirmOtp(TOKEN, '0000'); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(prisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 'REQUIRES_ACTION' }); + }); + + it('confirms an OTP even after the link TTL lapsed — the debit is already in flight', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + expiresAt: new Date(Date.now() - 60_000), + }); + + await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({ + status: 'SUCCEEDED', + }); + }); + + it('is idempotent once the charge is already paid', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + }); + + const result = await service.confirmOtp(TOKEN, '4530'); + + expect(result).toMatchObject({ alreadyPaid: true }); + expect(paymentClient.confirmOtp).not.toHaveBeenCalled(); + }); +}); + +/** + * CBE bill payment is inbound-only: no provider session is opened, a bill reference is minted and + * the payer settles it at a branch/app hours later. The expiry handed to the payment service is + * therefore the charge's own deadline, never the 30-minute link TTL — a short one would have the + * reconciliation sweep kill the intent within the hour (CBE plan §6.4). + */ +describe('ExcessBaggageService — CBE bill', () => { + const CHARGE_ID = 'charge-1'; + const TOKEN = 'tok-1'; + const THIRTY_MIN = 30 * 60 * 1000; + + let prisma: Record; + let paymentClient: { initiate: jest.Mock }; + let service: ExcessBaggageService; + let charge: any; + + beforeEach(() => { + charge = { + id: CHARGE_ID, + bookingId: 'booking-1', + totalMinor: 25_000, + currency: 'ETB', + status: 'PENDING', + // A freshly created charge: the short browser-session TTL. + expiresAt: new Date(Date.now() + THIRTY_MIN), + booking: { bookingRef: 'BAG-001' }, + }; + prisma = { + excessBaggageCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue(charge), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + seats: [{ leg: 1, passengerName: 'Abebe Kebede' }], + passenger: { user: { fullName: 'Account Holder' } }, + }), + }, + paymentMethod: { + findUnique: jest + .fn() + .mockResolvedValue({ type: 'CBE_BILL', currency: 'ETB' }), + }, + currencyExchangeRate: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }, + merchantOrderId: 'MO-1', + }), + }; + service = new ExcessBaggageService( + prisma as any, + { log: jest.fn() } as any, + new CurrencyService(prisma as any), + paymentClient as any, + {} as any, + {} as any, + {} as any, + ); + }); + + const initiate = () => + service.initiatePayment(TOKEN, { + method: PaymentMethodType.CBE_BILL, + platform: 'web', + } as any); + + it('extends the charge deadline past the 30-minute link TTL', async () => { + await initiate(); + + expect(prisma.excessBaggageCharge.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: CHARGE_ID }, + data: expect.objectContaining({ expiresAt: expect.any(Date) }), + }), + ); + const written = + prisma.excessBaggageCharge.update.mock.calls[0][0].data.expiresAt; + // Comfortably beyond the session TTL — a payer has to reach a branch. + expect(written.getTime()).toBeGreaterThan(Date.now() + 2 * THIRTY_MIN); + }); + + it('hands the payment service that deadline as the intent expiry, in ETB', async () => { + await initiate(); + + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(sent.currency).toBe('ETB'); + expect(sent.amountMinor).toBe(250); + expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan( + Date.now() + 2 * THIRTY_MIN, + ); + }); + + it('sends the lead passenger as Full_Name, which CBE requires', async () => { + await initiate(); + + expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe( + 'Abebe Kebede', + ); + }); + + it('never shortens a deadline the payer already has', async () => { + const farFuture = new Date(Date.now() + 90 * 60 * 60 * 1000); + charge.expiresAt = farFuture; + + await initiate(); + + expect(prisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBe( + farFuture.toISOString(), + ); + }); + + it('returns the bill reference to the caller', async () => { + const result = await initiate(); + expect(result.clientAction).toMatchObject({ + type: 'SHOW_BILL_REFERENCE', + billReference: '900123456', + }); + }); + + it('reports a paid charge through getStatus without the payability gate', async () => { + prisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + paidAt: new Date(), + }); + + // getByToken would throw "already paid" here; the poll must simply report it. + await expect(service.getStatus(TOKEN)).resolves.toMatchObject({ + status: 'PAID', + paid: true, + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 40a44f36b..0255c8a2b 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards, SetMetadata } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; import { LogExcessBaggageDto, WaiveChargeDto, InitiateExcessPaymentDto, + ConfirmExcessOtpDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { PassengerAdmin } from '../../common/passenger-guards'; @@ -28,7 +29,7 @@ export class ExcessBaggageAgentController { @Post() @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { - dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId; + dto.agentId = req.user?.id ?? req.user?.sub ?? ''; return this.service.logCharge(dto); } @@ -118,6 +119,37 @@ export class ExcessBaggagePublicController { return this.service.getByToken(token); } + @Get('pay/:token/amount') + @ApiOperation({ + summary: 'Quote the charge in a payment method’s settlement currency', + description: + 'Returns what the given method would debit, converted from the charge’s stored ETB total ' + + 'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' + + 'wallets in ETB) at the latest exchange rate. The pay page quotes this before the payer ' + + 'commits; initiating a payment recomputes it identically.', + }) + @ApiQuery({ + name: 'method', + required: true, + example: 'WAAFI', + description: 'Payment method type the payer has selected', + }) + quoteAmount(@Param('token') token: string, @Query('method') method: string) { + return this.service.quoteAmount(token, method); + } + + @Get('pay/:token/status') + @ApiOperation({ + summary: 'Poll the charge’s settlement status (public)', + description: + 'Reports the charge’s current status without the payability gate on GET /pay/:token, so a ' + + 'page can watch for settlement. Used while a CBE bill is outstanding and after a redirect ' + + 'payment returns — both settle server-side, out of band from the browser.', + }) + getStatus(@Param('token') token: string) { + return this.service.getStatus(token); + } + @Post('pay/:token/initiate') @ApiOperation({ summary: 'Passenger initiates payment for excess baggage charge' }) initiatePayment( @@ -126,4 +158,18 @@ export class ExcessBaggagePublicController { ) { return this.service.initiatePayment(token, dto); } + + @Post('pay/:token/confirm') + @ApiOperation({ + summary: 'Confirm an OTP-debit excess baggage payment (CAC Bank)', + description: + 'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' + + '400 and the payment stays open for retry.', + }) + confirmOtp( + @Param('token') token: string, + @Body() dto: ConfirmExcessOtpDto, + ) { + return this.service.confirmOtp(token, dto.otp); + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index df0abd8be..4b06e906e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -20,7 +20,34 @@ export class WaiveChargeDto { } export class InitiateExcessPaymentDto { - @ApiProperty({ enum: ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'DMONEY', 'CARD'] }) + @ApiProperty({ + enum: [ + 'TELEBIRR', + 'CBE_BIRR', + 'EBIRR', + 'WAAFI', + 'DMONEY', + 'CARD', + 'CAC_BANK', + 'CBE_BILL', + ], + }) @IsString() method: string; @ApiPropertyOptional({ enum: ['web', 'mobile'] }) @IsOptional() platform?: string; + @ApiPropertyOptional({ + description: + 'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' + + 'SMSes a one-time password to this number) and EBIRR (the wallet pushes a USSD PIN prompt ' + + 'to it). Normalised server-side by the payment service.', + example: '77123456', + }) + @IsOptional() @IsString() payerAccount?: string; +} + +export class ConfirmExcessOtpDto { + @ApiProperty({ + description: 'One-time password the payer received by SMS (CAC Bank).', + example: '4530', + }) + @IsString() otp: string; } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts index e734d4fb4..e9c648ca8 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts @@ -6,11 +6,18 @@ import { ExcessBaggagePublicController, } from './excess-baggage.controller'; import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { AuditModule } from '../../common/audit.module'; @Module({ - imports: [HttpModule, PaymentsModule, NotificationsModule, AuditModule], + imports: [ + HttpModule, + PaymentsModule, + CurrencyModule, + NotificationsModule, + AuditModule, + ], controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController], providers: [ExcessBaggageService], exports: [ExcessBaggageService], diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 32fc06c13..7ccc0af06 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -6,6 +6,16 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { snapshot } from '../../common/audit-snapshot'; + +const ALLOWANCE_AUDIT_FIELDS = [ + 'seatClassId', + 'maxWeightKg', + 'maxPiecesCount', + 'excessFeePerKg', +] as const; +import { CurrencyService } from '../currency/currency.service'; import { PaymentClientService } from '../payments/payment-client.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; @@ -25,6 +35,41 @@ import { PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; const CHARGE_TTL_MS = 30 * 60 * 1000; // 30 minutes +/** + * WALLET is an internal balance debit handled entirely inside this app (PaymentsService + * .initiateWalletPayment) — it is not a provider and the payment microservice rejects it as one. + * Excess baggage has no wallet path, so it is refused up front with a message a payer can act on + * rather than a 502 from the gateway layer. + */ +const UNSUPPORTED_METHODS = new Set([PaymentMethodType.WALLET]); + +/** + * Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time + * password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could + * collect the number later, so initiate is rejected without it (mirrors PaymentsService). + */ +const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ + PaymentMethodType.CAC_BANK, + PaymentMethodType.EBIRR, +]); + +/** + * How long an excess baggage charge stays payable once a CBE bill has been issued for it. + * + * The 30-minute link TTL is a browser-session window: it assumes the payer is sitting in front of + * the page. A CBE bill is the opposite — the payer walks to a branch, or opens CBE Birr later, and + * the bill reference may already be written on a slip of paper. Handing the payment service a + * 30-minute `expiresAt` would also make the reconciliation sweep expire the intent and emit + * payment.failed within the hour (CBE_IMPLEMENTATION_PLAN.md §6.4 calls this the single most + * important detail of the integration). + * + * So issuing a bill EXTENDS the charge's own deadline to this window. `charge.expiresAt` stays the + * single source of truth for both the pay link and the bill. + */ +const CBE_BILL_WINDOW_HOURS = Number( + process.env.EXCESS_BAGGAGE_CBE_BILL_HOURS ?? 24, +); + @Injectable() export class ExcessBaggageService { private readonly logger = new Logger(ExcessBaggageService.name); @@ -32,6 +77,7 @@ export class ExcessBaggageService { constructor( private prisma: PrismaService, private auditService: AuditService, + private currencyService: CurrencyService, private paymentClient: PaymentClientService, private notifications: NotificationsService, private smsClient: SmsClientService, @@ -94,7 +140,24 @@ export class ExcessBaggageService { await this.sendPaymentLink(charge, booking, contactPhone, contactEmail); } - await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: booking.id, excessWeightKg: dto.excessWeightKg, totalMinor, status } }); + // The charge row carries contactPhone/contactEmail for the payment link; those stay out of + // the audit payload, which needs only the money and who raised it. + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.ExcessBaggageCharge, + entityId: charge.id, + newData: { + bookingId: booking.id, + bookingRef: booking.bookingRef, + excessWeightKg: dto.excessWeightKg, + feePerKgMinor, + totalMinor, + currency: charge.currency, + status, + agentId: charge.agentId || null, + collectCash: dto.collectCash ?? false, + }, + }); return charge; } @@ -165,21 +228,103 @@ export class ExcessBaggageService { return charge; } + /** + * What the payer is actually charged when paying this charge with `method`. + * + * The charge itself is always booked in ETB (`ExcessBaggageCharge.currency` defaults to ETB and + * nothing overrides it), but the selected method settles in its own currency — WAAFI/DMONEY in + * DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row. The payment + * microservice is currency-agnostic and hands whatever it is given straight to the gateway + * verbatim, so the ETB→settlement conversion has to happen here or the provider is asked to debit + * an ETB number labelled as its own currency. + * + * Both the quote shown to the payer and the amount sent to the provider come through this one + * method, so the price on the button and the price debited cannot drift apart. + */ + private async resolveChargeAmount( + charge: { totalMinor: number; currency: string }, + method: string, + ): Promise<{ amount: number; currency: string }> { + if (UNSUPPORTED_METHODS.has(method)) { + throw new BadRequestException( + `${method} is not available for excess baggage payments`, + ); + } + + const paymentMethod = await this.prisma.paymentMethod.findUnique({ + where: { type: method as PaymentMethodType }, + }); + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted. Every other + // method charges in its configured settlement currency, falling back to the charge's own. + const chargeCurrency = + method === PaymentMethodType.CBE_BILL + ? 'ETB' + : (paymentMethod?.currency ?? charge.currency).toUpperCase(); + + // Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents. + const amount = await this.currencyService.convertMinorToChargeMajor( + charge.totalMinor, + charge.currency, + chargeCurrency, + ); + return { amount, currency: chargeCurrency }; + } + + /** + * Price quote for the pay page: what `method` would debit, in that method's settlement currency. + * The payer sees this before committing, and `initiatePayment` recomputes it the same way. + */ + async quoteAmount(token: string, method: string) { + const charge = await this.getByToken(token); + const { amount, currency } = await this.resolveChargeAmount(charge, method); + return { chargeId: charge.id, method, currency, amount }; + } + async initiatePayment(token: string, dto: InitiateExcessPaymentDto) { const charge = await this.getByToken(token); + if ( + METHODS_REQUIRING_PAYER_ACCOUNT.has(dto.method) && + !dto.payerAccount?.trim() + ) { + throw new BadRequestException( + `payerAccount (mobile number) is required for ${dto.method}`, + ); + } + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; const returnUrl = `${portalUrl}/excess-baggage/pay/${token}/result`; + const { amount, currency } = await this.resolveChargeAmount( + charge, + dto.method, + ); + + // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's + // system until someone pays it. It therefore needs a real deadline and a payer name (Full_Name + // is mandatory in CBE's envelope) rather than the redirect flow's session semantics. + let payerName: string | undefined; + let expiresAt: string | undefined; + if (dto.method === PaymentMethodType.CBE_BILL) { + const deadline = await this.extendForCbeBill(charge); + expiresAt = deadline.toISOString(); + payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; + } + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: 'EXCESS_BAGGAGE' as PaymentReferenceType, referenceId: charge.id, orderRef: `EXB-${charge.id.substring(0, 8).toUpperCase()}`, - amountMinor: charge.totalMinor / 100, - currency: charge.currency, + // `amountMinor` is the contract's name but its value is MAJOR units — the provider layer + // charges it verbatim at the currency's own precision (see PaymentIntentSnapshot). + amountMinor: amount, + currency, provider: dto.method as unknown as ProviderMethod, platform: dto.platform as any, + payerAccount: dto.payerAccount?.trim() || undefined, + payerName, + expiresAt, returnUrl, failureUrl: returnUrl, }); @@ -196,14 +341,150 @@ export class ExcessBaggageService { }; } + /** + * Pushes the charge's deadline out to the CBE bill window and returns it. Only ever extends — + * a charge that already has longer left (a re-issued bill, an agent's resend) keeps it, so + * re-initiating a bill can never shorten a window the payer was already given. + */ + private async extendForCbeBill(charge: { + id: string; + expiresAt: Date; + }): Promise { + const target = new Date(Date.now() + CBE_BILL_WINDOW_HOURS * 60 * 60 * 1000); + if (charge.expiresAt >= target) return charge.expiresAt; + + await this.prisma.excessBaggageCharge.update({ + where: { id: charge.id }, + data: { expiresAt: target }, + }); + this.logger.log( + `charge ${charge.id}: expiry extended to ${target.toISOString()} for CBE bill`, + ); + return target; + } + + /** + * Full_Name for CBE's confirmation screen — mandatory in its envelope. The passenger the + * baggage belongs to: lead traveller on the booking, falling back to the account holder. + */ + private async resolvePayerName(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, passenger: { include: { user: true } } }, + }); + if (!booking) return null; + return ( + booking.seats?.find((s: any) => s.leg === 1)?.passengerName ?? + booking.seats?.[0]?.passengerName ?? + booking.passenger?.user?.fullName ?? + null + ); + } + + /** + * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, + * expired or waived charge — the whole point is to report those states. A CBE bill can settle + * long after the payer closed the tab, and the redirect methods only converge when the + * settlement event lands, so the page needs something it can watch. + */ + async getStatus(token: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + select: { + id: true, + status: true, + paidAt: true, + totalMinor: true, + currency: true, + expiresAt: true, + }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + return { + chargeId: charge.id, + status: charge.status, + paid: charge.status === 'PAID' || charge.status === 'CASH_COLLECTED', + paidAt: charge.paidAt, + totalMinor: charge.totalMinor, + currency: charge.currency, + expiresAt: charge.expiresAt, + }; + } + + /** + * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the + * payerAccount given at initiate; this forwards it to the payment service and marks the charge + * paid when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays + * open, so the payer can simply re-enter it. + * + * Deliberately reads the charge directly rather than through getByToken: the bank is already + * holding a debit against this payer, and refusing to submit their OTP because the 30-minute + * link TTL lapsed while they were reading the SMS would strand a payment that is mid-flight. + */ + async confirmOtp(token: string, otp: string) { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { paymentToken: token }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID' || charge.status === 'CASH_COLLECTED') { + return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; + } + + const snapshot = await this.paymentClient.getIntentByReference( + 'EXCESS_BAGGAGE' as PaymentReferenceType, + charge.id, + ); + if (!snapshot) { + throw new NotFoundException('No active payment to confirm for this charge'); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, confirmed.providerTxnId); + } + + return { + chargeId: charge.id, + status: confirmed.status, + alreadyPaid: false, + }; + } + async markPaid(chargeId: string, providerTxnId?: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status === 'PAID') return charge; - return this.prisma.excessBaggageCharge.update({ - where: { id: chargeId }, + + // Conditional claim: PaymentsService.handleExcessBaggageChargeEvent drives the same + // transition from the webhook. Whichever caller actually flips the row writes the audit + // event, so the settlement is recorded exactly once regardless of which path won. + const { count } = await this.prisma.excessBaggageCharge.updateMany({ + where: { id: chargeId, status: { notIn: ['PAID', 'CASH_COLLECTED'] } }, data: { status: 'PAID', paidAt: new Date() }, }); + + const updated = await this.prisma.excessBaggageCharge.findUnique({ where: { id: chargeId } }); + + if (count === 1) { + await this.auditService.log({ + action: AUDIT_ACTIONS.PAY, + entityType: AUDIT_ENTITIES.ExcessBaggageCharge, + entityId: chargeId, + oldData: { status: charge.status }, + newData: { + status: 'PAID', + bookingId: charge.bookingId, + totalMinor: charge.totalMinor, + currency: charge.currency, + providerTxnId: providerTxnId ?? null, + }, + }); + } + + return updated!; } async waiveCharge(id: string, dto: WaiveChargeDto) { @@ -216,7 +497,22 @@ export class ExcessBaggageService { where: { id }, data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason }, }); - await this.auditService.log({ action: 'UPDATE', entityType: 'ExcessBaggageCharge', entityId: id, newData: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason } }); + // `dto.waivedBy` is a client-supplied label kept for the business column; the audit actor + // is resolved from the session by AuditService, so the two cannot disagree about who acted. + await this.auditService.log({ + action: AUDIT_ACTIONS.WAIVE, + entityType: AUDIT_ENTITIES.ExcessBaggageCharge, + entityId: id, + oldData: { status: charge.status }, + newData: { + status: 'WAIVED', + bookingId: charge.bookingId, + totalMinor: charge.totalMinor, + currency: charge.currency, + waivedBy: dto.waivedBy, + waivedReason: dto.waivedReason, + }, + }); return waived; } @@ -235,6 +531,16 @@ export class ExcessBaggageService { data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, }); await this.sendPaymentLink(updatedCharge, charge.booking, charge.contactPhone, charge.contactEmail); + await this.auditService.log({ + action: AUDIT_ACTIONS.RESEND, + entityType: AUDIT_ENTITIES.ExcessBaggageCharge, + entityId: id, + oldData: { expiresAt: charge.expiresAt?.toISOString() }, + newData: { + bookingRef: charge.booking.bookingRef, + expiresAt: updatedCharge.expiresAt?.toISOString(), + }, + }); return { sent: true }; } @@ -293,30 +599,79 @@ export class ExcessBaggageService { async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) { const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } }); if (existing) { - return this.prisma.baggageAllowance.update({ + const updated = await this.prisma.baggageAllowance.update({ where: { id: existing.id }, data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg }, }); + // An upsert, so report the edit rather than always claiming a create — this is a tariff + // change and the previous fee is the whole point of the row. + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.BaggageAllowance, + entityId: existing.id, + oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS), + newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS), + }); + return updated; } - return this.prisma.baggageAllowance.create({ + const created = await this.prisma.baggageAllowance.create({ data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.BaggageAllowance, + entityId: created.id, + newData: snapshot(created, ALLOWANCE_AUDIT_FIELDS), + }); + return created; } async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) { - return this.prisma.baggageAllowance.update({ where: { id }, data: dto }); + const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Baggage allowance not found'); + const updated = await this.prisma.baggageAllowance.update({ where: { id }, data: dto }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.BaggageAllowance, + entityId: id, + oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS), + newData: snapshot(updated, ALLOWANCE_AUDIT_FIELDS), + }); + return updated; } async deleteAllowance(id: string) { + const existing = await this.prisma.baggageAllowance.findUnique({ where: { id } }); await this.prisma.baggageAllowance.deleteMany({ where: { id } }); + if (existing) { + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.BaggageAllowance, + entityId: id, + oldData: snapshot(existing, ALLOWANCE_AUDIT_FIELDS), + }); + } return { deleted: true }; } async deleteCharge(id: string) { const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } }); if (!charge) throw new NotFoundException('Charge not found'); - + await this.prisma.excessBaggageCharge.delete({ where: { id } }); + // Hard delete of a money record — previously silent. + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.ExcessBaggageCharge, + entityId: id, + oldData: { + bookingId: charge.bookingId, + excessWeightKg: charge.excessWeightKg, + totalMinor: charge.totalMinor, + currency: charge.currency, + status: charge.status, + }, + }); return { deleted: true }; } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index a4c1d8ccf..103cbbc4b 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -4,6 +4,37 @@ import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoa import { SeatKind } from '@prisma/client'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { snapshot } from '../../common/audit-snapshot'; + +/** Fields carried into audit rows, per entity. Deliberately narrow — see audit-snapshot.ts. */ +const COACH_TYPE_AUDIT_FIELDS = ['code', 'name', 'type'] as const; +const SEAT_CLASS_AUDIT_FIELDS = [ + 'coachTypeId', + 'name', + 'description', + 'baseFareMinor', + 'premiumMinor', + 'insuranceFeeMinor', + 'isActive', +] as const; +const TRAIN_AUDIT_FIELDS = [ + 'number', + 'name', + 'operatorId', + 'operatorName', + 'description', + 'isActive', +] as const; +const COACH_AUDIT_FIELDS = [ + 'number', + 'coachTypeId', + 'arrangement', + 'capacity', + 'status', + 'sequence', +] as const; +const COACH_ASSIGNMENT_AUDIT_FIELDS = ['scheduleId', 'coachId', 'positionNumber', 'isOperational'] as const; // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] function parseArrangement(arrangement: string): number[] { @@ -151,7 +182,7 @@ export class FleetService { constructor(private prisma: PrismaService, private auditService: AuditService) {} async createCoachType(dto: CreateCoachTypeDto) { - return this.prisma.coachType.create({ + const coachType = await this.prisma.coachType.create({ data: { code: dto.code, name: dto.name, @@ -162,6 +193,13 @@ export class FleetService { coaches: true, }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.CoachType, + entityId: coachType.id, + newData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS), + }); + return coachType; } async getCoachTypes() { @@ -183,7 +221,7 @@ export class FleetService { if (dto.name !== undefined) data.name = dto.name; if (dto.type !== undefined) data.type = dto.type; - return this.prisma.coachType.update({ + const updated = await this.prisma.coachType.update({ where: { id }, data, include: { @@ -191,6 +229,14 @@ export class FleetService { coaches: true, }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.CoachType, + entityId: id, + oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS), + newData: snapshot(updated, COACH_TYPE_AUDIT_FIELDS), + }); + return updated; } async deleteCoachType(id: string) { @@ -224,11 +270,18 @@ export class FleetService { throw new DeleteOperationException('Coach Type', coachType.name, constraints); } - return this.prisma.coachType.delete({ where: { id } }); + const deleted = await this.prisma.coachType.delete({ where: { id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.CoachType, + entityId: id, + oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS), + }); + return deleted; } async createClass(dto: CreateClassDto) { - return this.prisma.seatClass.create({ + const seatClass = await this.prisma.seatClass.create({ data: { coachTypeId: dto.coachTypeId, name: dto.name, @@ -239,6 +292,13 @@ export class FleetService { ...(dto.isActive !== undefined && { isActive: dto.isActive }), }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.SeatClass, + entityId: seatClass.id, + newData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS), + }); + return seatClass; } async getClasses(coachTypeId?: string) { @@ -267,11 +327,19 @@ export class FleetService { updateData.isActive = dto.isActive; } - return this.prisma.seatClass.update({ + const updated = await this.prisma.seatClass.update({ where: { id }, data: updateData, include: { coachType: true }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.SeatClass, + entityId: id, + oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS), + newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS), + }); + return updated; } async deleteClass(id: string, cascade = false) { @@ -308,7 +376,15 @@ export class FleetService { await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } }); } - return this.prisma.seatClass.delete({ where: { id } }); + const deleted = await this.prisma.seatClass.delete({ where: { id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.SeatClass, + entityId: id, + oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS), + newData: { cascade }, + }); + return deleted; } createSeatClass(dto: CreateClassDto) { @@ -345,7 +421,12 @@ export class FleetService { isActive: dto.isActive ?? true, }, }); - await this.auditService.log({ action: 'CREATE', entityType: 'Train', entityId: train.id, newData: { number: train.number, name: train.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Train, + entityId: train.id, + newData: snapshot(train, TRAIN_AUDIT_FIELDS), + }); return train; } @@ -363,7 +444,13 @@ export class FleetService { ...(dto.isActive !== undefined && { isActive: dto.isActive }), }, }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Train', entityId: id, newData: { number: dto.number, name: dto.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Train, + entityId: id, + oldData: snapshot(train, TRAIN_AUDIT_FIELDS), + newData: snapshot(updated, TRAIN_AUDIT_FIELDS), + }); return updated; } @@ -442,14 +529,28 @@ export class FleetService { } const deleted = await this.prisma.train.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'Train', entityId: id, oldData: { number: train.number, name: train.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Train, + entityId: id, + oldData: snapshot(train, TRAIN_AUDIT_FIELDS), + newData: { cascade }, + }); return deleted; } async restoreTrain(id: string) { const train = await this.prisma.train.findUnique({ where: { id } }); if (!train) throw new NotFoundException('Train not found'); - return this.prisma.train.update({ where: { id }, data: { isActive: true } }); + const restored = await this.prisma.train.update({ where: { id }, data: { isActive: true } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.RESTORE, + entityType: AUDIT_ENTITIES.Train, + entityId: id, + oldData: { isActive: train.isActive }, + newData: { isActive: true }, + }); + return restored; } async getCoach(id: string) { @@ -526,7 +627,12 @@ export class FleetService { await this.prisma.seat.createMany({ data: seats }); } - await this.auditService.log({ action: 'CREATE', entityType: 'Coach', entityId: coach.id, newData: { number: coach.number, capacity: coach.capacity } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Coach, + entityId: coach.id, + newData: snapshot(coach, COACH_AUDIT_FIELDS), + }); return coach; } @@ -545,7 +651,13 @@ export class FleetService { }, include: { coachType: true }, }); - await this.auditService.log({ action: 'UPDATE', entityType: 'Coach', entityId: id, newData: { number: dto.number, status: dto.status } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Coach, + entityId: id, + oldData: snapshot(coach, COACH_AUDIT_FIELDS), + newData: snapshot(updated, COACH_AUDIT_FIELDS), + }); return updated; } @@ -622,7 +734,13 @@ export class FleetService { await this.prisma.seat.deleteMany({ where: { coachId: id } }); const deleted = await this.prisma.coach.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'Coach', entityId: id, oldData: { number: coach.number } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Coach, + entityId: id, + oldData: snapshot(coach, COACH_AUDIT_FIELDS), + newData: { cascade }, + }); return deleted; } @@ -634,13 +752,30 @@ export class FleetService { if (!schedule) throw new NotFoundException('Schedule not found'); if (!coach) throw new NotFoundException('Coach not found'); if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active'); - return this.prisma.coachAssignment.create({ data: dto }); + const assignment = await this.prisma.coachAssignment.create({ data: dto }); + await this.auditService.log({ + action: AUDIT_ACTIONS.ASSIGN, + entityType: AUDIT_ENTITIES.CoachAssignment, + entityId: assignment.id, + newData: { + ...snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS), + coachNumber: coach.number, + }, + }); + return assignment; } async removeAssignment(id: string) { const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } }); if (!assignment) throw new NotFoundException('Assignment not found'); - return this.prisma.coachAssignment.delete({ where: { id } }); + const deleted = await this.prisma.coachAssignment.delete({ where: { id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UNASSIGN, + entityType: AUDIT_ENTITIES.CoachAssignment, + entityId: id, + oldData: snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS), + }); + return deleted; } async generateSeatMapPreview(dto: GenerateSeatMapDto) { diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 9ef922bb3..3b7e7be2f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -8,6 +8,7 @@ import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; +import { buildSeatSummary } from '../../common/utils/booking-sms.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -305,7 +306,7 @@ export class NotificationsService { where: { id: bookingId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } }, orderBy: { leg: 'asc' } }, }, }); @@ -367,30 +368,19 @@ export class NotificationsService { } /** - * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a - * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get - * several lines). + * Builds the interpolation context for the `booking.created` template. `passengerName` and + * `trainSeatLines` both come from buildSeatSummary — a solo booking is greeted by name with + * bare "coach, seat no." lines, while a group is greeted as "Passengers" and each line names + * its own occupant (one SMS goes to Booking.contactPhone for the whole party). */ private buildBookingCreatedContext(booking: any, ref: string): Record { const s = booking?.schedule ?? {}; - const trainName = s.train?.name ?? s.train?.number ?? ''; const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD'; const fmtTime = (d: any) => d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD'; - const seats = booking?.seats ?? []; - const trainSeatLines = seats - .map((bs: any) => { - const coach = bs.seat?.coach?.number ?? '-'; - const cls = bs.seat?.coach?.coachType?.name ?? ''; - const seatNo = bs.seat?.seatNumber ?? '-'; - return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim(); - }) - .join('\n'); - - // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. - const passengerName = seats[0]?.passengerName ?? 'Passenger'; + const { passengerName, trainSeatLines } = buildSeatSummary(booking?.seats ?? [], booking?.bookingType); const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts index df5949bde..1b82389fb 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentReferenceType } from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { PaymentEventDto, @@ -51,6 +52,17 @@ export class InternalPaymentsController { async billQuery( @Body() request: BillQueryRequestDto, ): Promise { + // Routed on referenceType: the passenger app issues CBE bills for bookings AND for excess + // baggage charges, and they live in different tables. Treating every referenceId as a + // bookingId would report a perfectly payable baggage bill as NOT_FOUND to the teller. + if (request.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.paymentsService.billQueryExcessBaggage(request.referenceId); + } + if (request.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) { + return this.paymentsService.billQuerySupplementaryCharge( + request.referenceId, + ); + } return this.paymentsService.billQuery(request.referenceId); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 9c1bfa65d..04ace216f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Query, + Req, Res, SetMetadata, UseGuards, @@ -38,6 +39,7 @@ import { } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; +import { resolveActingUser } from "../../common/acting-user"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; import { SupplementaryChargesService } from "./supplementary-charges.service"; import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator"; @@ -57,6 +59,18 @@ class WaiveSupplementaryChargeDto { class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; + @ApiPropertyOptional({ + description: + 'Payer account / mobile number. Required for the push-debit methods: CAC_BANK (the bank ' + + 'SMSes a one-time password to it) and EBIRR (the wallet pushes a USSD PIN prompt to it).', + example: '77123456', + }) + @IsOptional() @IsString() payerAccount?: string; +} + +class ConfirmSupplementaryOtpDto { + @ApiProperty({ description: 'One-time password the payer received by SMS (CAC Bank).', example: '4530' }) + @IsString() otp: string; } @ApiTags("Payment") @@ -211,17 +225,14 @@ export class PaymentsController { } @Post(":bookingId/force-confirm") - @PassengerStaff([ - PASSENGER_PERMS.payments.manage, - PASSENGER_PERMS.payments.manageMethods, - PASSENGER_PERMS.admin, - ]) + @PassengerStaff([PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") @ApiOperation({ - summary: "Force-confirm payment & generate ticket (back-office only)", + summary: "Force-confirm payment & generate ticket (ticket-generate permission)", description: "Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " + - "Use when a vendor payment completed but the webhook was never delivered. Idempotent.", + "Use when a vendor payment completed but the webhook was never delivered. Idempotent. " + + "Requires `edr_passenger_app:tickets:generate` (admins bypass).", }) forceConfirm( @Param("bookingId") bookingId: string, @@ -377,13 +388,13 @@ export class PaymentsController { @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) - createSupplementaryCharge( - @Body() dto: CreateSupplementaryChargeDto, - @Headers('x-iam-user-id') iamUserId?: string, - ) { + createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) { + // Actor comes from the guarded session, not the client-settable `x-iam-user-id` header it + // used to read (which defaulted to the literal string 'staff'). + const actor = resolveActingUser(req); return this.supplementaryService.create({ ...dto, - createdBy: iamUserId ?? 'staff', + createdBy: actor?.id ?? 'staff', }); } @@ -416,6 +427,52 @@ export class PaymentsController { return this.supplementaryService.getByToken(token); } + @Get('supplementary/by-token/:token/amount') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Quote a supplementary charge in a payment method’s settlement currency (public)', + description: + 'Returns what the given method would debit, converted from the charge’s stored ETB amount ' + + 'to that method’s settlement currency (WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian ' + + 'wallets in ETB). The self-pay page quotes this before the payer commits; paying recomputes ' + + 'it identically.', + }) + @ApiQuery({ name: 'method', required: true, example: 'WAAFI' }) + quoteSupplementaryAmount( + @Param('token') token: string, + @Query('method') method: string, + ) { + return this.supplementaryService.quoteAmount(token, method); + } + + @Get('supplementary/by-token/:token/status') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Poll a supplementary charge’s settlement status (public)', + description: + 'Reports the charge’s current status without the payability gate on the by-token lookup, ' + + 'so a page can watch for settlement that happens out of band (a CBE bill paid at a branch, ' + + 'or a redirect payment confirmed by webhook).', + }) + getSupplementaryStatus(@Param('token') token: string) { + return this.supplementaryService.getStatus(token); + } + + @Post('supplementary/by-token/:token/confirm') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Confirm an OTP-debit balance payment (CAC Bank, public — self-pay)', + description: + 'Submits the one-time password the payer received by SMS. A wrong or expired OTP returns ' + + '400 and the payment stays open for retry.', + }) + confirmSupplementaryOtp( + @Param('token') token: string, + @Body() dto: ConfirmSupplementaryOtpDto, + ) { + return this.supplementaryService.confirmOtp(token, dto.otp); + } + @Post('supplementary/by-token/:token/pay') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' }) @@ -433,6 +490,7 @@ export class PaymentsController { dto.method, dto.platform, resolveAllowedOrigin(origin, referer, frontendBaseUrl), + dto.payerAccount, ); } @@ -454,9 +512,10 @@ export class PaymentsController { waiveSupplementaryCharge( @Param('id') id: string, @Body() dto: WaiveSupplementaryChargeDto, - @Headers('x-iam-user-id') iamUserId?: string, + @Req() req: any, ) { - return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff'); + const actor = resolveActingUser(req); + return this.supplementaryService.waive(id, dto.notes ?? '', actor?.id ?? 'staff'); } @Post('supplementary/:id/resend') diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 9b94053b6..6c8f4b58d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -46,6 +46,18 @@ describe("PaymentsService", () => { paymentMethod: { findUnique: jest.fn(), }, + excessBaggageCharge: { + findUnique: jest.fn(), + update: jest.fn(), + // The charge handlers claim the PAID transition with a conditional updateMany so the + // in-app path and this webhook cannot both write an audit row for one settlement. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + supplementaryCharge: { + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, currencyExchangeRate: { findFirst: jest.fn(), }, @@ -562,4 +574,268 @@ describe("PaymentsService", () => { ); }); }); + + /** + * Excess baggage settles through the same outbox → RabbitMQ path as bookings. Before this + * existed the consumer dropped every EXCESS_BAGGAGE event as "foreign-reference", so a charge + * the payer had genuinely paid stayed PENDING until its TTL flipped it to EXPIRED. + */ + describe("handlePaymentEvent — excess baggage", () => { + const CHARGE_ID = "charge-1"; + + const succeededEvent = (overrides: Record = {}) => + ({ + eventId: "evt-1", + eventType: "payment.succeeded", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.EXCESS_BAGGAGE, + referenceId: CHARGE_ID, + amountMinor: 500, + currency: "ETB", + providerTxnId: "TXN-9", + ...overrides, + }) as any; + + it("marks a pending charge PAID", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: CHARGE_ID }), + data: expect.objectContaining({ status: "PAID" }), + }), + ); + expect(result).toEqual({ processed: true }); + }); + + it("marks an EXPIRED charge PAID — the TTL governs starting a payment, not receiving one", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "EXPIRED", + }); + + await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("does not re-pay an already PAID charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PAID", + }); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(mockPrisma.excessBaggageCharge.updateMany).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true, alreadyFinalized: true }); + }); + + it("accepts a foreign-currency settlement without a short-pay comparison", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + id: CHARGE_ID, + status: "PENDING", + }); + + // 500.00 ETB charge settled as 1625 DJF — numerically unlike the stored total. + await service.handlePaymentEvent( + succeededEvent({ amountMinor: 1625, currency: "DJF" }), + ); + + expect(mockPrisma.excessBaggageCharge.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "PAID" }), + }), + ); + }); + + it("acks a failure event without touching the charge", async () => { + const result = await service.handlePaymentEvent( + succeededEvent({ eventType: "payment.failed" }), + ); + + expect(mockPrisma.excessBaggageCharge.update).not.toHaveBeenCalled(); + expect(result).toEqual({ processed: true }); + }); + + it("acks an event for a charge that no longer exists", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + const result = await service.handlePaymentEvent(succeededEvent()); + + expect(result).toEqual({ + processed: false, + reason: "charge-not-found", + }); + }); + }); + + /** + * The live hop CBE makes while a teller is on the line, for a baggage bill. This is the + * double-payment guard: anything other than stillPayable=true makes CBE refuse the debit. + */ + describe("billQueryExcessBaggage", () => { + const payable = { + id: "charge-1", + excessWeightKg: 7, + totalMinor: 25_000, + status: "PENDING", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + booking: { + bookingRef: "BAG-001", + seats: [{ leg: 1, passengerName: "Abebe Kebede" }], + passenger: { user: { fullName: "Account Holder" } }, + }, + }; + + it("reports a pending charge as payable, in ETB, with the passenger name", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(payable); + + const result = await service.billQueryExcessBaggage("charge-1"); + + expect(result).toMatchObject({ + stillPayable: true, + currency: "ETB", + currentAmountMinor: 250, + payerName: "Abebe Kebede", + }); + expect(result.paymentReason).toContain("BAG-001"); + }); + + it("refuses a charge already paid at the counter in cash", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "CASH_COLLECTED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ + stillPayable: false, + reason: "ALREADY_PAID", + }); + }); + + it("refuses a waived charge", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + status: "WAIVED", + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "CANCELLED" }); + }); + + it("refuses a charge whose deadline has passed", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("refuses within the settle margin, so a debit cannot land after expiry", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() + 5_000), // inside the 60s margin + }); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toMatchObject({ stillPayable: false, reason: "EXPIRED" }); + }); + + it("reports NOT_FOUND for a bill whose charge is gone", async () => { + mockPrisma.excessBaggageCharge.findUnique.mockResolvedValue(null); + + await expect( + service.billQueryExcessBaggage("charge-1"), + ).resolves.toEqual({ stillPayable: false, reason: "NOT_FOUND" }); + }); + }); + /** + * The live hop CBE makes while a teller is on the line, for a balance bill. Same + * double-payment guard as bookings and baggage: anything other than stillPayable=true makes + * CBE refuse the debit. + */ + describe("billQuerySupplementaryCharge", () => { + const payable = { + id: "sc-1", + amountMinor: 100_000, + status: "PENDING", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + booking: { + bookingRef: "BAL-001", + seats: [{ leg: 1, passengerName: "Abebe Kebede" }], + passenger: { user: { fullName: "Account Holder" } }, + }, + }; + + it("reports a pending charge as payable, in ETB, with the passenger name", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(payable); + const result = await service.billQuerySupplementaryCharge("sc-1"); + expect(result).toMatchObject({ + stillPayable: true, + currency: "ETB", + currentAmountMinor: 1000, + payerName: "Abebe Kebede", + }); + expect(result.paymentReason).toContain("BAL-001"); + }); + + it("refuses an already paid charge", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "PAID" }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: false, + reason: "ALREADY_PAID", + }); + }); + + it("refuses a waived charge", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, status: "WAIVED" }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: false, + reason: "CANCELLED", + }); + }); + + it("refuses within the settle margin so a debit cannot land after expiry", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ + ...payable, + expiresAt: new Date(Date.now() + 5_000), + }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: false, + reason: "EXPIRED", + }); + }); + + it("treats a null expiry as an open-ended debt, still payable", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue({ ...payable, expiresAt: null }); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toMatchObject({ + stillPayable: true, + }); + }); + + it("reports NOT_FOUND for a bill whose charge is gone", async () => { + mockPrisma.supplementaryCharge.findUnique.mockResolvedValue(null); + await expect(service.billQuerySupplementaryCharge("sc-1")).resolves.toEqual({ + stillPayable: false, + reason: "NOT_FOUND", + }); + }); + }); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5cd483830..f0cedf3f5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -43,6 +43,17 @@ import { } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from "../../common/audit.actions"; + +const PAYMENT_METHOD_AUDIT_FIELDS = [ + "type", + "displayName", + "region", + "currency", + "providerId", + "enabled", + "sortOrder", +] as const; import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util"; import { PaymentService as PaymentServiceEnum, @@ -92,6 +103,18 @@ export class PaymentsService { }); if (!intent) throw new NotFoundException("Payment intent not found"); await this.prisma.paymentIntent.delete({ where: { id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Payment, + entityId: id, + oldData: { + bookingId: intent.bookingId, + status: intent.status, + amountMinor: intent.amountMinor, + currency: intent.currency, + method: intent.method, + }, + }); return { deleted: true, id }; } @@ -533,6 +556,139 @@ export class PaymentsService { return { ...base, stillPayable: true, reason: null }; } + /** + * Bill-query for an excess baggage charge — the same live "still payable?" hop as bookings, + * against `ExcessBaggageCharge` instead. This is the double-payment guard for baggage bills: + * once the charge is paid, waived or lapsed, CBE is told to refuse the debit. + * + * The charge's own `expiresAt` is the deadline (extended to the CBE bill window when the bill + * was issued), so there is no separate schedule-derived deadline to compute as there is for a + * booking. + */ + async billQueryExcessBaggage( + chargeId: string, + ): Promise { + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: chargeId }, + include: { + booking: { + include: { seats: true, passenger: { include: { user: true } } }, + }, + }, + }); + // A bill reference we issued whose charge has since been deleted — a data problem, not a + // customer-facing cancellation. + if (!charge) return { stillPayable: false, reason: "NOT_FOUND" }; + + const base = { + payerName: + charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ?? + charge.booking?.seats?.[0]?.passengerName ?? + charge.booking?.passenger?.user?.fullName ?? + null, + // The charge is always booked in ETB and CBE settles ETB only, so no conversion applies. + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + charge.totalMinor, + "ETB", + ), + currency: "ETB", + // Rendered beside the amount on CBE's confirmation screen. The weight and booking ref are + // both on the agent's slip, so the payer can match the two before confirming. + paymentReason: `Excess baggage ${charge.excessWeightKg}kg — booking ${ + charge.booking?.bookingRef ?? "" + }`.trim(), + }; + + // Paid first: a charge settled by any method (including cash at the counter) must be reported + // as already paid, never as merely "not payable". + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + // A supervisor wrote the charge off; from the payer's side the debt is gone. + if (charge.status === "WAIVED") { + return { ...base, stillPayable: false, reason: "CANCELLED" }; + } + // Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline + // that the sweep expires the intent before the capture is registered. + if ( + charge.status === "EXPIRED" || + charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < + Date.now() + ) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + if (charge.status !== "PENDING") { + return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; + } + return { ...base, stillPayable: true, reason: null }; + } + + /** + * Bill-query for a supplementary charge — the same live "still payable?" hop as bookings, + * against `SupplementaryCharge`. This is the double-payment guard for balance bills: once the + * charge is paid, waived or lapsed, CBE is told to refuse the debit. + * + * The charge's own 72-hour `expiresAt` is the deadline. It is nullable — a charge raised with + * no expiry is an open-ended debt and stays payable indefinitely, which is the intended reading + * of a null here rather than an immediate refusal. + */ + async billQuerySupplementaryCharge( + chargeId: string, + ): Promise { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { id: chargeId }, + include: { + booking: { + include: { seats: true, passenger: { include: { user: true } } }, + }, + }, + }); + // A bill reference we issued whose charge has since been deleted — a data problem, not a + // customer-facing cancellation. + if (!charge) return { stillPayable: false, reason: "NOT_FOUND" }; + + const base = { + payerName: + charge.booking?.seats?.find((s) => s.leg === 1)?.passengerName ?? + charge.booking?.seats?.[0]?.passengerName ?? + charge.booking?.passenger?.user?.fullName ?? + null, + // The charge is raised in ETB and CBE settles ETB only, so no conversion applies. + currentAmountMinor: this.currencyService.displayMinorToChargeMajor( + charge.amountMinor, + "ETB", + ), + currency: "ETB", + // Rendered beside the amount on CBE's confirmation screen. The booking ref is on the + // passenger's ticket, so they can match the two before confirming. + paymentReason: `Outstanding balance — booking ${ + charge.booking?.bookingRef ?? "" + }`.trim(), + }; + + if (charge.status === "PAID") { + return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; + } + // Staff wrote the balance off; from the payer's side the debt is gone. + if (charge.status === "WAIVED") { + return { ...base, stillPayable: false, reason: "CANCELLED" }; + } + // Confirmed CBE debits land in seconds, but must not be accepted so close to the deadline + // that the sweep expires the intent before the capture is registered. + if ( + charge.status === "EXPIRED" || + (charge.expiresAt && + charge.expiresAt.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < + Date.now()) + ) { + return { ...base, stillPayable: false, reason: "EXPIRED" }; + } + if (charge.status !== "PENDING") { + return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; + } + return { ...base, stillPayable: true, reason: null }; + } + /** * The booking's payment deadline, resolved exactly like the auto-cancel job: the booking's * origin-segment time and that stop's own check-in window, falling back to the route default. @@ -942,16 +1098,27 @@ export class PaymentsService { data: { status: "CANCELLED" }, }); } + // The intent is moved to CANCELLED, not "REFUNDED" — recording the latter made the audit + // row contradict the row it describes. await this.auditService.log({ - action: "UPDATE", - entityType: "Payment", + action: AUDIT_ACTIONS.REFUND, + entityType: AUDIT_ENTITIES.Payment, entityId: intent.id, - newData: { status: "REFUNDED", bookingId: dto.bookingId }, + oldData: { status: intent.status, bookingStatus: booking?.status }, + newData: { + status: "CANCELLED", + bookingId: dto.bookingId, + bookingRef: booking?.bookingRef, + bookingStatus: booking ? "CANCELLED" : undefined, + amountMinor: intent.amountMinor, + currency: intent.currency, + reason: dto.reason, + }, }); return { refunded: true, bookingRef: booking?.bookingRef }; } - addPaymentMethod(dto: AddPaymentMethodDto) { + async addPaymentMethod(dto: AddPaymentMethodDto) { const data = { type: dto.type as unknown as PaymentMethodType, displayName: dto.displayName, @@ -961,11 +1128,37 @@ export class PaymentsService { enabled: dto.enabled ?? true, sortOrder: dto.sortOrder ?? 0, }; - return this.prisma.paymentMethod.upsert({ + + // This is an upsert keyed on `type`, so "add" silently overwrites an existing method. The + // audit row reports which of the two actually happened rather than always claiming a create. + const existing = await this.prisma.paymentMethod.findUnique({ + where: { type: data.type }, + }); + + const method = await this.prisma.paymentMethod.upsert({ where: { type: data.type }, update: data, create: data, }); + + await this.auditService.log({ + action: existing ? AUDIT_ACTIONS.UPDATE : AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.PaymentMethod, + entityId: method.id, + oldData: existing ? this.paymentMethodSnapshot(existing) : undefined, + newData: this.paymentMethodSnapshot(method), + }); + + return method; + } + + private paymentMethodSnapshot(method: Record) { + return Object.fromEntries( + PAYMENT_METHOD_AUDIT_FIELDS.filter((k) => method[k] !== undefined).map((k) => [ + k, + method[k], + ]), + ); } async updatePaymentMethod(id: string, dto: Partial) { @@ -983,10 +1176,20 @@ export class PaymentsService { if (dto.enabled !== undefined) updateData.enabled = dto.enabled; if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder; - return this.prisma.paymentMethod.update({ + const updated = await this.prisma.paymentMethod.update({ where: { id }, data: updateData, }); + + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.PaymentMethod, + entityId: id, + oldData: this.paymentMethodSnapshot(existing), + newData: this.paymentMethodSnapshot(updated), + }); + + return updated; } getSupportedPaymentMethods(region?: PaymentRegionEnum) { @@ -1379,20 +1582,112 @@ export class PaymentsService { } if (charge.status === "PAID") return { processed: true, alreadyFinalized: true }; - await this.prisma.supplementaryCharge.update({ - where: { id: charge.id }, + // Conditional claim, not a plain update: SupplementaryChargesService.markPaid can be + // driving the same transition from the in-app path. Whichever caller flips the row writes + // the audit event; the loser writes nothing, so the trail holds exactly one PAID row. + const { count } = await this.prisma.supplementaryCharge.updateMany({ + where: { id: charge.id, status: { not: "PAID" } }, data: { status: "PAID", paidAt: new Date(), providerTxnId: event.providerTxnId ?? null, }, }); - await this.auditService.log({ - action: "UPDATE", - entityType: "SupplementaryCharge", - entityId: charge.id, - newData: { status: "PAID", providerTxnId: event.providerTxnId }, + + if (count === 1) { + await this.auditService.log({ + action: AUDIT_ACTIONS.PAY, + entityType: AUDIT_ENTITIES.SupplementaryCharge, + entityId: charge.id, + oldData: { status: charge.status }, + newData: { + status: "PAID", + bookingId: charge.bookingId, + providerTxnId: event.providerTxnId, + settledAmount: event.amountMinor, + settledCurrency: event.currency, + }, + }); + } + return { processed: true, alreadyFinalized: count === 0 }; + } + + /** + * Settlement for an excess baggage charge paid through the passenger portal link. + * + * Deliberately has NO short-payment amount guard, unlike the booking path: the charge is stored + * in ETB while `event.amountMinor` arrives in the provider's settlement currency (DJF for + * Waafi/D-Money/CAC, USD for card), so comparing the two directly would reject every legitimate + * cross-currency payment. The amount actually charged was computed server-side at initiate. + * + * An EXPIRED charge is still marked PAID. The link TTL only governs whether a NEW payment may be + * started; once a provider has captured the money the charge is paid, and leaving it EXPIRED + * would hide a real settlement from the agent who has to reconcile it. + */ + private async handleExcessBaggageChargeEvent( + event: PaymentEventDto, + ): Promise { + if (event.eventType === "payment.failed") { + this.logger.warn( + `excess baggage charge ${event.referenceId} payment failed`, + ); + return { processed: true }; + } + + const charge = await this.prisma.excessBaggageCharge.findUnique({ + where: { id: event.referenceId }, }); + if (!charge) { + // Ack — a missing charge will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no excess baggage charge for reference ${event.referenceId}`, + ); + return { processed: false, reason: "charge-not-found" }; + } + if (charge.status === "PAID" || charge.status === "CASH_COLLECTED") { + return { processed: true, alreadyFinalized: true }; + } + // Money arrived against a charge nobody expected to be paid — record it as PAID (that is the + // truth) but say so loudly: a waived charge that settles anyway needs a refund decision. + if (charge.status !== "PENDING") { + this.logger.warn( + `mark-paid: excess baggage charge ${charge.id} settled while ${charge.status} ` + + `(${event.amountMinor} ${event.currency}) — marking PAID; needs review`, + ); + } + + // Conditional claim for the same reason as the supplementary handler above: + // ExcessBaggageService.markPaid drives this transition from the in-app path. + const { count } = await this.prisma.excessBaggageCharge.updateMany({ + where: { id: charge.id, status: { notIn: ["PAID", "CASH_COLLECTED"] } }, + data: { + status: "PAID", + // The provider's own capture time, not when this event happened to be processed — a + // replayed or dead-lettered event must not backdate the money to the wrong minute. + paidAt: event.paidAt ? new Date(event.paidAt) : new Date(), + }, + }); + + if (count === 0) { + return { processed: true, alreadyFinalized: true }; + } + + await this.auditService.log({ + action: AUDIT_ACTIONS.PAY, + entityType: AUDIT_ENTITIES.ExcessBaggageCharge, + entityId: charge.id, + oldData: { status: charge.status }, + newData: { + status: "PAID", + bookingId: charge.bookingId, + providerTxnId: event.providerTxnId, + settledAmount: event.amountMinor, + settledCurrency: event.currency, + }, + }); + this.logger.log( + `excess baggage charge ${charge.id} marked PAID (${event.amountMinor} ${event.currency}, txn ${event.providerTxnId ?? "n/a"})`, + ); return { processed: true }; } @@ -1410,6 +1705,10 @@ export class PaymentsService { return this.handleSupplementaryChargeEvent(event); } + if (event.referenceType === PaymentReferenceType.EXCESS_BAGGAGE) { + return this.handleExcessBaggageChargeEvent(event); + } + if (event.referenceType !== PaymentReferenceType.BOOKING) { this.logger.warn( `mark-paid: ignoring unknown referenceType ${event.referenceType}`, @@ -1565,6 +1864,9 @@ export class PaymentsService { let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, }); + // Captured before the block below rewrites it, so the audit row can show what the override + // moved the payment away from. + const previousStatus = intent?.status ?? null; if (!intent) { intent = await this.prisma.paymentIntent.create({ data: { @@ -1603,17 +1905,23 @@ export class PaymentsService { providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined, force: true, }).then(async (result) => { - await this.auditService.log({ - action: "UPDATE", - entityType: "Payment", - entityId: intent.id, - newData: { - status: "FORCE_CONFIRMED", - bookingId, - paymentMethod: dto.paymentMethod, - paymentReference: dto.paymentReference, - }, - }); + // finalizePaymentSuccess reports `alreadyFinalized` when the booking was already + // confirmed — logging a forced confirmation there would record an override that changed + // nothing. + if (!result?.alreadyFinalized) { + await this.auditService.log({ + action: AUDIT_ACTIONS.STATUS_CHANGE, + entityType: AUDIT_ENTITIES.Payment, + entityId: intent.id, + oldData: { status: previousStatus }, + newData: { + status: "FORCE_CONFIRMED", + bookingId, + paymentMethod: dto.paymentMethod, + paymentReference: dto.paymentReference, + }, + }); + } return result; }); } diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts new file mode 100644 index 000000000..28188f70f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts @@ -0,0 +1,195 @@ +import { SupplementaryChargesService } from './supplementary-charges.service'; + +/** + * Supplementary charges are raised by staff against a confirmed booking, so "who raised this, + * against which booking, and who later waived it" has to survive in AuditLog. + * + * The actor used to come from an `x-iam-user-id` request header defaulting to the literal + * string 'staff' — a client-settable value in the column meant to identify a person. + */ +describe('SupplementaryChargesService — audit', () => { + const CHARGE_ID = 'sc-1'; + const BOOKING_ID = 'booking-1'; + const BOOKING_REF = 'EDR-0001'; + + let prisma: Record; + let audit: { log: jest.Mock }; + let service: SupplementaryChargesService; + + const build = (charge: Record = {}) => { + const row = { + id: CHARGE_ID, + bookingId: BOOKING_ID, + reason: 'UNDERPAYMENT', + amountMinor: 25000, + currency: 'ETB', + status: 'PENDING', + paymentToken: 'tok-live-secret', + notes: null, + createdBy: 'iam-staff-1', + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + booking: { bookingRef: BOOKING_REF, contactPhone: '+251911223344', contactEmail: 'p@example.com' }, + ...charge, + }; + + prisma = { + supplementaryCharge: { + findUnique: jest.fn().mockResolvedValue(row), + create: jest.fn().mockResolvedValue(row), + update: jest.fn().mockResolvedValue({ ...row, status: 'WAIVED' }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + id: BOOKING_ID, + bookingRef: BOOKING_REF, + status: 'CONFIRMED', + contactPhone: '+251911223344', + contactEmail: 'p@example.com', + passenger: { user: { phone: null, email: null } }, + }), + }, + }; + audit = { log: jest.fn().mockResolvedValue(undefined) }; + + // Constructor order: prisma, audit, sms, email, paymentClient, currency. + service = new SupplementaryChargesService( + prisma as any, + audit as any, + { sendSms: jest.fn() } as any, + { sendEmail: jest.fn() } as any, + {} as any, + {} as any, + ); + return row; + }; + + const rows = () => audit.log.mock.calls.map((c) => c[0]); + const byAction = (action: string) => rows().filter((r) => r.action === action); + + describe('create', () => { + it('records one CREATE naming the booking the charge belongs to', async () => { + build(); + await service.create({ + bookingRef: BOOKING_REF, + amountMinor: 25000, + reason: 'UNDERPAYMENT', + createdBy: 'iam-staff-1', + }); + + expect(byAction('CREATE')).toHaveLength(1); + expect(byAction('CREATE')[0]).toMatchObject({ + entityType: 'SupplementaryCharge', + entityId: CHARGE_ID, + }); + expect(byAction('CREATE')[0].newData).toMatchObject({ + bookingId: BOOKING_ID, + bookingRef: BOOKING_REF, + amountMinor: 25000, + reason: 'UNDERPAYMENT', + createdBy: 'iam-staff-1', + }); + }); + + it('never writes the payment token into the row', async () => { + build(); + await service.create({ + bookingRef: BOOKING_REF, + amountMinor: 25000, + reason: 'UNDERPAYMENT', + createdBy: 'iam-staff-1', + }); + expect(JSON.stringify(rows())).not.toContain('tok-live-secret'); + }); + + it('records nothing when the booking is not chargeable', async () => { + build(); + prisma.booking.findUnique.mockResolvedValue({ + id: BOOKING_ID, + bookingRef: BOOKING_REF, + status: 'PENDING_PAYMENT', + }); + + await expect( + service.create({ + bookingRef: BOOKING_REF, + amountMinor: 25000, + reason: 'UNDERPAYMENT', + createdBy: 'iam-staff-1', + }), + ).rejects.toThrow(); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); + + describe('waive', () => { + it('records WAIVE rather than a generic UPDATE', async () => { + build(); + await service.waive(CHARGE_ID, 'goodwill', 'iam-staff-1'); + + expect(byAction('WAIVE')).toHaveLength(1); + expect(byAction('UPDATE')).toHaveLength(0); + expect(byAction('WAIVE')[0]).toMatchObject({ + entityType: 'SupplementaryCharge', + entityId: CHARGE_ID, + oldData: { status: 'PENDING' }, + }); + expect(byAction('WAIVE')[0].newData).toMatchObject({ status: 'WAIVED', waivedBy: 'iam-staff-1' }); + }); + + it('records nothing when waiving a paid charge is refused', async () => { + build({ status: 'PAID' }); + await expect(service.waive(CHARGE_ID, 'x', 'iam-staff-1')).rejects.toThrow(); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); + + describe('markPaid', () => { + it('records PAY once when it claims the transition', async () => { + build(); + await service.markPaid(CHARGE_ID, 'TXN-9'); + + expect(byAction('PAY')).toHaveLength(1); + expect(byAction('PAY')[0]).toMatchObject({ + entityType: 'SupplementaryCharge', + entityId: CHARGE_ID, + oldData: { status: 'PENDING' }, + }); + }); + + it('stays silent when the webhook already claimed it', async () => { + build(); + prisma.supplementaryCharge.updateMany.mockResolvedValue({ count: 0 }); + await service.markPaid(CHARGE_ID, 'TXN-9'); + + expect(byAction('PAY')).toHaveLength(0); + }); + + it('is a no-op on a charge already read as PAID', async () => { + build({ status: 'PAID' }); + await service.markPaid(CHARGE_ID); + + expect(audit.log).not.toHaveBeenCalled(); + expect(prisma.supplementaryCharge.updateMany).not.toHaveBeenCalled(); + }); + }); + + describe('actor', () => { + it('never sets userId at the call site — AuditService reads the session', async () => { + build(); + await service.waive(CHARGE_ID, 'goodwill', 'client-supplied'); + expect(rows().every((r) => r.userId === undefined)).toBe(true); + }); + }); + + describe('reads', () => { + it('writes nothing when listing charges', async () => { + build(); + prisma.supplementaryCharge.findMany = jest.fn().mockResolvedValue([]); + prisma.supplementaryCharge.count = jest.fn().mockResolvedValue(0); + + await service.getAll({}); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 079792366..714b02170 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -1,14 +1,39 @@ import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { SmsClientService } from '../notifications/sms-client.service'; import { EmailClientService } from '../notifications/email-client.service'; import { PaymentClientService } from './payment-client.service'; -import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; +import { CurrencyService } from '../currency/currency.service'; +import { + PaymentReferenceType, + PaymentService as PaymentServiceEnum, + ProviderMethod, + ProviderPaymentStatus, +} from '@edr/types'; import { PaymentPlatformDto } from './payments.dto'; +import { PaymentMethodType } from '@prisma/client'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours +/** + * WALLET is an internal balance debit handled inside this app, not a provider — the payment + * microservice rejects it as one. Supplementary charges have no wallet path, so it is refused up + * front with a message the payer can act on rather than a 502 from the gateway layer. + */ +const UNSUPPORTED_METHODS = new Set([PaymentMethodType.WALLET]); + +/** + * Push-debit methods charge an account we must be told up front — CAC Bank SMSes a one-time + * password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could + * collect the number later (mirrors PaymentsService and ExcessBaggageService). + */ +const METHODS_REQUIRING_PAYER_ACCOUNT = new Set([ + PaymentMethodType.CAC_BANK, + PaymentMethodType.EBIRR, +]); + @Injectable() export class SupplementaryChargesService { private readonly logger = new Logger(SupplementaryChargesService.name); @@ -19,6 +44,7 @@ export class SupplementaryChargesService { private smsClient: SmsClientService, private emailClient: EmailClientService, private paymentClient: PaymentClientService, + private currencyService: CurrencyService, ) {} async create(dto: { @@ -55,10 +81,20 @@ export class SupplementaryChargesService { await this.sendLink(charge, booking.bookingRef, phone, email); await this.auditService.log({ - action: 'CREATE', - entityType: 'SupplementaryCharge', + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.SupplementaryCharge, entityId: charge.id, - newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason }, + newData: { + bookingId: booking.id, + bookingRef: dto.bookingRef, + amountMinor: dto.amountMinor, + currency: charge.currency, + reason: dto.reason, + notes: dto.notes, + createdBy: dto.createdBy, + status: charge.status, + expiresAt: charge.expiresAt?.toISOString(), + }, }); return charge; } @@ -109,12 +145,174 @@ export class SupplementaryChargesService { const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); if (!charge) throw new NotFoundException('Charge not found'); if (charge.status === 'PAID') return charge; - const updated = await this.prisma.supplementaryCharge.update({ - where: { id }, + + // Conditional update rather than a plain update: this transition is also reachable from the + // payment webhook, and claiming it atomically means exactly one of the two racing callers + // writes the audit row. + const { count } = await this.prisma.supplementaryCharge.updateMany({ + where: { id, status: { not: 'PAID' } }, data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null }, }); - await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } }); - return updated; + + const updated = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + + if (count === 1) { + await this.auditService.log({ + action: AUDIT_ACTIONS.PAY, + entityType: AUDIT_ENTITIES.SupplementaryCharge, + entityId: id, + oldData: { status: charge.status }, + newData: { + status: 'PAID', + bookingId: charge.bookingId, + amountMinor: charge.amountMinor, + currency: charge.currency, + providerTxnId: providerTxnId ?? null, + }, + }); + } + + return updated!; + } + + /** + * What the payer is actually charged when settling this charge with `method`. + * + * The charge is raised in ETB, but the selected method settles in its own currency — WAAFI and + * D-Money in DJF, CARD in USD, the Ethiopian wallets in ETB — recorded on the PaymentMethod row. + * The payment microservice is currency-agnostic and hands whatever it is given straight to the + * gateway, so the ETB->settlement conversion has to happen here or the provider is asked to + * debit an ETB number labelled as its own currency. + * + * Both the quote shown to the payer and the amount sent to the provider come through this one + * method, so the price on the button and the price debited cannot drift apart. + */ + private async resolveChargeAmount( + charge: { amountMinor: number; currency: string }, + method: string, + ): Promise<{ amount: number; currency: string }> { + if (UNSUPPORTED_METHODS.has(method)) { + throw new BadRequestException( + `${method} is not available for balance payments`, + ); + } + + const paymentMethod = await this.prisma.paymentMethod.findUnique({ + where: { type: method as PaymentMethodType }, + }); + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8) — never converted. + const chargeCurrency = + method === PaymentMethodType.CBE_BILL + ? 'ETB' + : (paymentMethod?.currency ?? charge.currency).toUpperCase(); + + // Applies the target currency's own precision — DJF rounds to whole francs, ETB/USD to cents. + const amount = await this.currencyService.convertMinorToChargeMajor( + charge.amountMinor, + charge.currency, + chargeCurrency, + ); + return { amount, currency: chargeCurrency }; + } + + /** + * Price quote for the pay page: what `method` would debit, in that method's settlement + * currency. The payer sees this before committing, and pay() recomputes it the same way. + */ + async quoteAmount(token: string, method: string) { + const charge = await this.getByToken(token); + const { amount, currency } = await this.resolveChargeAmount(charge, method); + return { chargeId: charge.id, method, currency, amount }; + } + + /** + * Bare status for the pay/result pages to poll. Unlike getByToken this does NOT reject a paid, + * expired or waived charge — reporting those states is the entire point. A CBE bill can settle + * long after the payer closed the tab, and redirect methods only converge when the settlement + * event lands, so the page needs something it can watch. + */ + async getStatus(token: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + select: { + id: true, + status: true, + paidAt: true, + amountMinor: true, + currency: true, + expiresAt: true, + }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + return { + chargeId: charge.id, + status: charge.status, + paid: charge.status === 'PAID', + paidAt: charge.paidAt, + amountMinor: charge.amountMinor, + currency: charge.currency, + expiresAt: charge.expiresAt, + }; + } + + /** + * Full_Name for CBE's confirmation screen — mandatory in its envelope. The traveller the balance + * is owed against: lead passenger on the booking, falling back to the account holder. + */ + private async resolvePayerName(bookingId: string): Promise { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: { seats: true, passenger: { include: { user: true } } }, + }); + if (!booking) return null; + return ( + booking.seats?.find((s: any) => s.leg === 1)?.passengerName ?? + booking.seats?.[0]?.passengerName ?? + booking.passenger?.user?.fullName ?? + null + ); + } + + /** + * Submit the one-time password for a COLLECT_OTP provider (CAC Bank). The bank SMSed it to the + * payerAccount given at pay(); this forwards it to the payment service and marks the charge paid + * when the debit settles. A wrong or expired OTP bubbles up as a 400 and the intent stays open, + * so the payer can simply re-enter it. + * + * Deliberately reads the charge directly rather than through getByToken: the bank is already + * holding a debit against this payer, and refusing to submit their OTP because the link TTL + * lapsed while they read the SMS would strand a payment that is mid-flight. + */ + async confirmOtp(token: string, otp: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID') { + return { chargeId: charge.id, status: 'SUCCEEDED', alreadyPaid: true }; + } + + const snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.SUPPLEMENTARY_CHARGE, + charge.id, + ); + if (!snapshot) { + throw new NotFoundException('No active payment to confirm for this charge'); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markPaid(charge.id, confirmed.providerTxnId); + } + + return { + chargeId: charge.id, + status: confirmed.status, + alreadyPaid: false, + }; } async pay( @@ -122,9 +320,16 @@ export class SupplementaryChargesService { method: string, platform?: PaymentPlatformDto, requestOrigin?: string | null, + payerAccount?: string, ) { const charge = await this.getByToken(token); // validates status/expiry + if (METHODS_REQUIRING_PAYER_ACCOUNT.has(method) && !payerAccount?.trim()) { + throw new BadRequestException( + `payerAccount (mobile number) is required for ${method}`, + ); + } + const paymentMethod = method as ProviderMethod; // Self-pay links are opened on whichever portal domain the recipient used // (bookingedr.et vs passenger.edrsc.com), so the return pages must live on @@ -135,15 +340,38 @@ export class SupplementaryChargesService { const returnUrl = `${portalUrl}/pay-balance/${token}/success`; const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; + const { amount, currency } = await this.resolveChargeAmount(charge, method); + + // CBE_BILL is inbound-only: no provider session is opened, the bill simply sits in CBE's + // system until someone pays it. It needs a real deadline and a payer name (Full_Name is + // mandatory in CBE's envelope) rather than the redirect flow's session semantics. + // + // Unlike an excess baggage charge (30-minute link TTL), this charge already carries a 72-hour + // deadline of its own, which is a sane bill lifetime — so it is passed straight through with + // no extension. That deadline is what stops the reconciliation sweep from expiring the intent + // early (CBE_IMPLEMENTATION_PLAN.md §6.4). A charge with no expiry at all yields no intent + // expiry either, which is correct: an open-ended debt backs an open-ended bill. + let payerName: string | undefined; + let expiresAt: string | undefined; + if (method === PaymentMethodType.CBE_BILL) { + expiresAt = charge.expiresAt?.toISOString(); + payerName = (await this.resolvePayerName(charge.bookingId)) ?? undefined; + } + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE, referenceId: charge.id, orderRef: `SC-${charge.id.substring(0, 8)}`, - amountMinor: charge.amountMinor / 100, - currency: charge.currency, + // `amountMinor` is the contract's name but its value is MAJOR units — the provider layer + // charges it verbatim at the currency's own precision (see PaymentIntentSnapshot). + amountMinor: amount, + currency, provider: paymentMethod, platform, + payerAccount: payerAccount?.trim() || undefined, + payerName, + expiresAt, returnUrl, failureUrl, }); @@ -159,7 +387,20 @@ export class SupplementaryChargesService { where: { id }, data: { status: 'WAIVED', notes }, }); - await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.WAIVE, + entityType: AUDIT_ENTITIES.SupplementaryCharge, + entityId: id, + oldData: { status: charge.status }, + newData: { + status: 'WAIVED', + bookingId: charge.bookingId, + amountMinor: charge.amountMinor, + currency: charge.currency, + waivedBy, + notes, + }, + }); return updated; } @@ -175,6 +416,19 @@ export class SupplementaryChargesService { data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, }); await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail); + // Re-exposes a live payment token and extends its deadline, so it is a state change worth + // attributing even though the charge's status is unchanged. Contact details stay out of the + // row — only the fact that a link was re-sent. + await this.auditService.log({ + action: AUDIT_ACTIONS.RESEND, + entityType: AUDIT_ENTITIES.SupplementaryCharge, + entityId: id, + oldData: { expiresAt: charge.expiresAt?.toISOString() }, + newData: { + bookingRef: charge.booking.bookingRef, + expiresAt: updated.expiresAt?.toISOString(), + }, + }); return { sent: true }; } diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts new file mode 100644 index 000000000..043ec919c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts @@ -0,0 +1,249 @@ +import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodType } from '@prisma/client'; +import { SupplementaryChargesService } from './supplementary-charges.service'; +import { CurrencyService } from '../currency/currency.service'; + +/** + * A supplementary charge is raised in ETB, but each payment method settles in its own currency and + * the payment microservice forwards whatever it is given straight to the gateway. These cover the + * ETB->settlement conversion, plus the two methods that could not complete at all before: CAC Bank + * (OTP debit) and CBE (inbound bill). + */ +describe('SupplementaryChargesService — payment methods', () => { + const CHARGE_ID = 'sc-1'; + const TOKEN = 'tok-1'; + + let prisma: Record; + let paymentClient: Record; + let service: SupplementaryChargesService; + let charge: any; + + const build = (rate?: { rate: number }) => { + charge = { + id: CHARGE_ID, + bookingId: 'booking-1', + amountMinor: 100_000, + currency: 'ETB', + status: 'PENDING', + expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000), + booking: { bookingRef: 'BAL-001' }, + }; + prisma = { + supplementaryCharge: { + findUnique: jest.fn().mockResolvedValue(charge), + update: jest.fn().mockResolvedValue({ ...charge, status: 'PAID' }), + // markPaid claims the PAID transition conditionally so it cannot double-log with the + // webhook path; count: 1 means this caller won. + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + booking: { + findUnique: jest.fn().mockResolvedValue({ + seats: [{ leg: 1, passengerName: 'Abebe Kebede' }], + passenger: { user: { fullName: 'Account Holder' } }, + }), + }, + paymentMethod: { findUnique: jest.fn() }, + currencyExchangeRate: { + findFirst: jest.fn().mockResolvedValue(rate ?? null), + }, + }; + paymentClient = { + initiate: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + clientAction: { type: 'REDIRECT', url: 'https://gw.test/pay' }, + }), + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: 'intent-1', status: 'REQUIRES_ACTION' }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: 'intent-1', + status: 'SUCCEEDED', + providerTxnId: 'CAC-77', + }), + }; + service = new SupplementaryChargesService( + prisma as any, + { log: jest.fn() } as any, + {} as any, + {} as any, + paymentClient as any, + new CurrencyService(prisma as any), + ); + }; + + const withMethod = (type: string, currency: string) => + prisma.paymentMethod.findUnique.mockResolvedValue({ type, currency }); + + describe('currency', () => { + it('charges an Ethiopian wallet in ETB, unconverted', async () => { + build(); + withMethod(PaymentMethodType.TELEBIRR, 'ETB'); + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.TELEBIRR); + expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 }); + expect(prisma.currencyExchangeRate.findFirst).not.toHaveBeenCalled(); + }); + + it('converts to DJF and rounds to whole francs', async () => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.DMONEY, 'DJF'); + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.DMONEY); + expect(quote).toMatchObject({ currency: 'DJF', amount: 3250 }); + expect(Number.isInteger(quote.amount)).toBe(true); + }); + + it('sends the provider the converted amount, not the stored ETB total', async () => { + build({ rate: 0.018 }); + withMethod(PaymentMethodType.CARD, 'USD'); + await service.pay(TOKEN, PaymentMethodType.CARD, 'web' as any, null); + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + referenceType: 'SUPPLEMENTARY_CHARGE', + amountMinor: 18, + currency: 'USD', + }), + ); + }); + + it('quotes and charges the same figure', async () => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.WAAFI); + await service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null); + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(quote.amount).toBe(sent.amountMinor); + expect(quote.currency).toBe(sent.currency); + }); + + it('refuses WALLET, which has no supplementary-charge path', async () => { + build(); + await expect( + service.quoteAmount(TOKEN, PaymentMethodType.WALLET), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('fails closed when no exchange rate is configured', async () => { + build(); + withMethod(PaymentMethodType.WAAFI, 'DJF'); + await expect( + service.pay(TOKEN, PaymentMethodType.WAAFI, 'web' as any, null), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + }); + + describe('CAC Bank OTP debit', () => { + beforeEach(() => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.CAC_BANK, 'DJF'); + }); + + it('rejects pay() without a payer mobile', async () => { + await expect( + service.pay(TOKEN, PaymentMethodType.CAC_BANK, 'web' as any, null), + ).rejects.toBeInstanceOf(BadRequestException); + expect(paymentClient.initiate).not.toHaveBeenCalled(); + }); + + it('forwards the trimmed payer mobile', async () => { + await service.pay( + TOKEN, + PaymentMethodType.CAC_BANK, + 'web' as any, + null, + ' 77123456 ', + ); + expect(paymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ payerAccount: '77123456', currency: 'DJF' }), + ); + }); + + it('submits the OTP against the active intent and marks the charge paid', async () => { + const result = await service.confirmOtp(TOKEN, '4530'); + expect(paymentClient.getIntentByReference).toHaveBeenCalledWith( + 'SUPPLEMENTARY_CHARGE', + CHARGE_ID, + ); + expect(paymentClient.confirmOtp).toHaveBeenCalledWith('intent-1', '4530'); + expect(prisma.supplementaryCharge.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'PAID', + providerTxnId: 'CAC-77', + }), + }), + ); + expect(result).toMatchObject({ status: 'SUCCEEDED', alreadyPaid: false }); + }); + + it('leaves the charge unpaid when the OTP does not settle', async () => { + paymentClient.confirmOtp.mockResolvedValue({ + intentId: 'intent-1', + status: 'REQUIRES_ACTION', + }); + await service.confirmOtp(TOKEN, '0000'); + expect(prisma.supplementaryCharge.update).not.toHaveBeenCalled(); + expect(prisma.supplementaryCharge.updateMany).not.toHaveBeenCalled(); + }); + + it('is idempotent once already paid', async () => { + prisma.supplementaryCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + }); + await expect(service.confirmOtp(TOKEN, '4530')).resolves.toMatchObject({ + alreadyPaid: true, + }); + expect(paymentClient.confirmOtp).not.toHaveBeenCalled(); + }); + }); + + describe('CBE bill', () => { + beforeEach(() => { + build({ rate: 3.25 }); + withMethod(PaymentMethodType.CBE_BILL, 'DJF'); // misconfigured row must not win + }); + + it('forces ETB regardless of the PaymentMethod row', async () => { + const quote = await service.quoteAmount(TOKEN, PaymentMethodType.CBE_BILL); + expect(quote).toMatchObject({ currency: 'ETB', amount: 1000 }); + }); + + it('passes the charge own 72h deadline as the intent expiry', async () => { + await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null); + const sent = paymentClient.initiate.mock.calls[0][0]; + expect(sent.currency).toBe('ETB'); + expect(sent.expiresAt).toBe(charge.expiresAt.toISOString()); + // Comfortably longer than a browser-session TTL, so the sweep cannot kill the bill early. + expect(new Date(sent.expiresAt).getTime()).toBeGreaterThan( + Date.now() + 24 * 60 * 60 * 1000, + ); + }); + + it('sends the lead passenger as Full_Name, which CBE requires', async () => { + await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null); + expect(paymentClient.initiate.mock.calls[0][0].payerName).toBe( + 'Abebe Kebede', + ); + }); + + it('leaves the intent expiry unset for an open-ended charge', async () => { + charge.expiresAt = null; + await service.pay(TOKEN, PaymentMethodType.CBE_BILL, 'web' as any, null); + expect(paymentClient.initiate.mock.calls[0][0].expiresAt).toBeUndefined(); + }); + + it('reports a paid charge through getStatus without the payability gate', async () => { + prisma.supplementaryCharge.findUnique.mockResolvedValue({ + ...charge, + status: 'PAID', + paidAt: new Date(), + }); + await expect(service.getStatus(TOKEN)).resolves.toMatchObject({ + status: 'PAID', + paid: true, + }); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 1eea923fd..55f19dae0 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -37,6 +37,24 @@ export class ReportsController { return this.service.getPassengerList(scheduleId); } + // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. + @Get("passengers/overview") + @ApiOperation({ + summary: "Fleet-wide passenger mix across a departure window", + description: + "Landing view for the passengers report, shown before a schedule is picked. Returns passenger volume per " + + "departure day, nationality split, passenger-category mix and the busiest origin→destination pairs across " + + "the window, plus one row per schedule.\n\n" + + "The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " + + "back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" + + "Counts CONFIRMED and BOARDED seats only, matching `GET /reports/passengers`. Carries no occupancy figure " + + "by design: this report and the seat status report measure capacity differently, so a shared occupancy " + + "number would contradict one of them.", + }) + getPassengerOverview(@Query('days') days?: string) { + return this.service.getPassengerOverview(days ? Number(days) : undefined); + } + @Get("passengers") @ApiOperation({ summary: "Passengers report for a specific schedule" }) getOccupancyReport(@Query("scheduleId") scheduleId: string) { @@ -60,6 +78,23 @@ export class ReportsController { return this.service.getSeatStatusReport(scheduleId); } + // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. + @Get("seat-status/overview") + @ApiOperation({ + summary: "Fleet-wide seat status across a departure window", + description: + "Landing view for the seat status report, shown before a schedule is picked. Returns the same four " + + "counters as the per-schedule report (paid, unpaid, expired holds, blocked) rolled up over a window of " + + "departures, plus per-day buckets and one row per schedule.\n\n" + + "The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " + + "back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" + + "Counts apply the same rules as `GET /reports/seat-status`, so a schedule's row here equals what the " + + "drill-down shows after selecting it.", + }) + getSeatStatusOverview(@Query('days') days?: string) { + return this.service.getSeatStatusOverview(days ? Number(days) : undefined); + } + @Get("boarding") @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) getBoardingReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index e899fdb4b..89a01dd0d 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; +import { BookingStatus } from "@prisma/client"; import { BlockedSeatRevenueLossReport, UNCATEGORIZED_REASON_CATEGORY, @@ -35,6 +36,48 @@ const FARE_QUOTE_CONCURRENCY = 4; /** CSV export is not paginated, but still needs an upper bound. */ const CSV_EXPORT_MAX_SCHEDULES = 5000; +// ── Fleet seat-status overview (landing view of the seat status report) ────── +const MS_PER_DAY_OVERVIEW = 24 * 60 * 60 * 1000; +const OVERVIEW_DEFAULT_DAYS = 7; +const OVERVIEW_MAX_DAYS = 31; +/** Upper bound on schedules charted at once. Signalled back as `window.truncated`. */ +const OVERVIEW_MAX_SCHEDULES = 60; +/** The booking statuses that put a seat on a schedule — same set as the drill-down. */ +const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ + 'CONFIRMED', + 'BOARDED', + 'PENDING_PAYMENT', +]; + +/** The passengers report counts people, so a seat awaiting payment does not qualify. */ +const PASSENGER_ACTIVE_BOOKING_STATUSES: BookingStatus[] = ['CONFIRMED', 'BOARDED']; +/** Route pairs are long-tailed; only the busiest are legible in a chart. */ +const TOP_ROUTES_LIMIT = 8; + +const EMPTY_OVERVIEW_TOTALS = { + scheduleCount: 0, + sellableSeats: 0, + paidCount: 0, + unpaidCount: 0, + expiredHoldCount: 0, + blockedCount: 0, + availableCount: 0, + loadFactorPercent: 0, +}; + +function emptyDayBucket(date: string) { + return { + date, + scheduleCount: 0, + sellableSeats: 0, + paid: 0, + unpaid: 0, + expiredHolds: 0, + blocked: 0, + available: 0, + }; +} + const EMPTY_LOSS_INPUT: LossCalculatorInput = { schedules: [], seatsById: new Map(), @@ -728,6 +771,603 @@ export class ReportsService { }; } + /** + * Fleet-wide seat status across a departure window — the landing view for the seat + * status report, shown before a schedule is picked. + * + * Deliberately a separate method from {@link getSeatStatusReport}: that one answers + * "this schedule, row by row" and its response shape is consumed by the drill-down UI. + * This one answers "the whole window, counts only". They share no code path, but they + * *do* share predicates — every filter below is the same rule the drill-down applies + * (the three-branch seat `OR`, the dining-coach exclusion, the counted-block + * resolution), so a schedule's row here always equals what you see after clicking it. + * Change one and the other must change with it. + */ + async getSeatStatusOverview(daysRaw?: number) { + const days = Math.min( + Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1), + OVERVIEW_MAX_DAYS, + ); + const now = new Date(); + + // Forward-looking by default. But a database whose schedules are all in the past + // would render an empty chart, which reads as a broken page rather than an honest + // "nothing departing" — so fall back to the most recent window that has departures. + let from = now; + let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW); + let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING'; + + const upcomingCount = await this.prisma.trainSchedule.count({ + where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, + }); + + if (upcomingCount === 0) { + const latest = await this.prisma.trainSchedule.findFirst({ + where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } }, + orderBy: { departureAt: 'desc' }, + select: { departureAt: true }, + }); + if (latest) { + direction = 'RECENT'; + to = latest.departureAt; + from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW); + } + } + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, + select: { + id: true, + departureAt: true, + isPackageOnly: true, + train: { select: { number: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: 'asc' }, + take: OVERVIEW_MAX_SCHEDULES, + }); + + if (schedules.length === 0) { + return { + window: { from, to, days, direction, truncated: false }, + totals: EMPTY_OVERVIEW_TOTALS, + byDay: [], + schedules: [], + }; + } + + const scheduleIds = schedules.map((s) => s.id); + const since24h = new Date(now.getTime() - MS_PER_DAY_OVERVIEW); + + const [assignments, bookingSeats, holds, blockRows] = await Promise.all([ + this.prisma.coachAssignment.findMany({ + where: { scheduleId: { in: scheduleIds } }, + select: { + scheduleId: true, + coachId: true, + coach: { + select: { + coachType: { select: { name: true, type: true } }, + seats: { select: { seatNumber: true } }, + }, + }, + }, + }), + // Same three-branch OR as the drill-down (`getSeatStatusReport`): a seat reaches a + // schedule by its own `scheduleId`, by being leg 2 of a return booking, or — on + // older rows with no `scheduleId` — by its booking's outbound schedule. A plain + // `groupBy scheduleId` would silently drop the last two. + this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { + scheduleId: { in: scheduleIds }, + booking: { status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES } }, + }, + { + leg: 2, + booking: { + returnScheduleId: { in: scheduleIds }, + status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES }, + }, + }, + { + scheduleId: null, + leg: 1, + booking: { + scheduleId: { in: scheduleIds }, + status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES }, + }, + }, + ], + }, + select: { + scheduleId: true, + leg: true, + booking: { + select: { status: true, scheduleId: true, returnScheduleId: true }, + }, + seat: { + select: { + coach: { select: { coachType: { select: { name: true, type: true } } } }, + }, + }, + }, + }), + this.prisma.seatHold.findMany({ + where: { + scheduleId: { in: scheduleIds }, + expiresAt: { lt: now, gte: since24h }, + }, + select: { scheduleId: true }, + }), + this.prisma.seatBlock.findMany({ + where: { + OR: [{ scheduleId: { in: scheduleIds } }, { scheduleId: null }], + NOT: [ + { reason: { startsWith: 'MAINTENANCE:' } }, + { reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } }, + ], + }, + select: { + scheduleId: true, + blockedAt: true, + unblockAt: true, + seat: { + select: { + id: true, + coachId: true, + seatNumber: true, + coach: { select: { coachType: { select: { name: true, type: true } } } }, + }, + }, + }, + orderBy: { blockedAt: 'desc' }, + }), + ]); + + // ── Sellable seats and assigned coaches, per schedule ─────────────────────── + // Sellable = every seat on every assigned coach, minus dining coaches and + // placeholder rows — the same denominator the revenue-loss report uses. + const sellableBySchedule = new Map(); + const coachIdsBySchedule = new Map>(); + for (const assignment of assignments) { + const coachIds = + coachIdsBySchedule.get(assignment.scheduleId) ?? new Set(); + coachIds.add(assignment.coachId); + coachIdsBySchedule.set(assignment.scheduleId, coachIds); + + const coachType = assignment.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + const sellable = (assignment.coach?.seats ?? []).filter( + (seat) => !isPlaceholderSeat(seat), + ).length; + sellableBySchedule.set( + assignment.scheduleId, + (sellableBySchedule.get(assignment.scheduleId) ?? 0) + sellable, + ); + } + + // ── Paid / unpaid, per schedule ──────────────────────────────────────────── + const scheduleIdSet = new Set(scheduleIds); + const paidBySchedule = new Map(); + const unpaidBySchedule = new Map(); + for (const bs of bookingSeats) { + // Dining seats only — the drill-down does not drop placeholder rows from the + // passenger seat list, so neither does this. + const coachType = bs.seat?.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + + // Mirrors the OR branches, in the same order: an explicit `scheduleId` inside the + // window wins, then the return leg, then the booking's outbound schedule. + const scheduleId = + bs.scheduleId && scheduleIdSet.has(bs.scheduleId) + ? bs.scheduleId + : bs.leg === 2 + ? bs.booking.returnScheduleId + : bs.booking.scheduleId; + if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue; + + const target = + bs.booking.status === 'PENDING_PAYMENT' ? unpaidBySchedule : paidBySchedule; + target.set(scheduleId, (target.get(scheduleId) ?? 0) + 1); + } + + // ── Expired holds, per schedule ──────────────────────────────────────────── + const holdsBySchedule = new Map(); + for (const hold of holds) { + holdsBySchedule.set( + hold.scheduleId, + (holdsBySchedule.get(hold.scheduleId) ?? 0) + 1, + ); + } + + // ── Blocked seats, per schedule ──────────────────────────────────────────── + // One counted block per seat, resolved exactly as the drill-down resolves it: a + // schedule-scoped block beats a global one, and rows arrive newest-first so the + // first of a kind seen for a seat is already the most recent. + const blockedBySchedule = new Map(); + for (const schedule of schedules) { + const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set(); + const countedSeatIds = new Map(); + + for (const block of blockRows) { + const seat = block.seat; + if (!seat || isPlaceholderSeat(seat)) continue; + + const coachType = seat.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + + if (block.scheduleId !== null) { + if (block.scheduleId !== schedule.id) continue; + } else { + if (!assignedCoachIds.has(seat.coachId)) continue; + if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue; + } + + const existing = countedSeatIds.get(seat.id); + if (existing === undefined || (existing === null && block.scheduleId !== null)) { + countedSeatIds.set(seat.id, block.scheduleId); + } + } + + if (countedSeatIds.size > 0) { + blockedBySchedule.set(schedule.id, countedSeatIds.size); + } + } + + // ── Assemble ─────────────────────────────────────────────────────────────── + const scheduleRows = schedules.map((s) => { + const sellableSeats = sellableBySchedule.get(s.id) ?? 0; + const paid = paidBySchedule.get(s.id) ?? 0; + const unpaid = unpaidBySchedule.get(s.id) ?? 0; + const expiredHolds = holdsBySchedule.get(s.id) ?? 0; + const blocked = blockedBySchedule.get(s.id) ?? 0; + // Floored at zero: a seat can be both sold and blocked, so the parts can + // over-subtract. Never render a negative slice. + const available = Math.max(0, sellableSeats - paid - unpaid - blocked); + + return { + scheduleId: s.id, + trainNumber: s.train.number, + originStation: s.originStation.name, + destinationStation: s.destinationStation.name, + departureAt: s.departureAt, + isPackage: s.isPackageOnly, + sellableSeats, + paid, + unpaid, + expiredHolds, + blocked, + available, + loadFactorPercent: + sellableSeats > 0 ? +((paid / sellableSeats) * 100).toFixed(1) : 0, + }; + }); + + // Day buckets keyed on the UTC calendar date of departure, so the chart's axis and + // its bars are derived from one value and cannot disagree with each other. + const byDayMap = new Map>(); + for (const row of scheduleRows) { + const date = row.departureAt.toISOString().slice(0, 10); + const bucket = byDayMap.get(date) ?? emptyDayBucket(date); + bucket.scheduleCount += 1; + bucket.sellableSeats += row.sellableSeats; + bucket.paid += row.paid; + bucket.unpaid += row.unpaid; + bucket.expiredHolds += row.expiredHolds; + bucket.blocked += row.blocked; + bucket.available += row.available; + byDayMap.set(date, bucket); + } + const byDay = [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)); + + const sum = (pick: (r: (typeof scheduleRows)[number]) => number) => + scheduleRows.reduce((total, row) => total + pick(row), 0); + + const totalSellable = sum((r) => r.sellableSeats); + const totalPaid = sum((r) => r.paid); + + return { + window: { + from, + to, + days, + direction, + truncated: schedules.length === OVERVIEW_MAX_SCHEDULES, + }, + totals: { + scheduleCount: scheduleRows.length, + sellableSeats: totalSellable, + paidCount: totalPaid, + unpaidCount: sum((r) => r.unpaid), + expiredHoldCount: sum((r) => r.expiredHolds), + blockedCount: sum((r) => r.blocked), + availableCount: sum((r) => r.available), + loadFactorPercent: + totalSellable > 0 ? +((totalPaid / totalSellable) * 100).toFixed(1) : 0, + }, + byDay, + schedules: scheduleRows, + }; + } + + /** + * Fleet-wide passenger mix across a departure window — the landing view for the + * passengers report, shown before a schedule is picked. + * + * Answers "who travelled", not "how full were the trains". Occupancy is deliberately + * absent: this report and the seat status report count capacity differently (this one + * includes dining and placeholder seats in `totalSeats`, the other does not), so an + * occupancy figure here would either contradict the table below it or the seats page. + * That pre-existing difference is left alone rather than silently reconciled. + * + * Counts CONFIRMED and BOARDED only, matching {@link getOccupancyBySchedule} — a seat + * awaiting payment has no passenger on it yet. + */ + async getPassengerOverview(daysRaw?: number) { + const days = Math.min( + Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1), + OVERVIEW_MAX_DAYS, + ); + const now = new Date(); + + // Window resolution is intentionally a copy of the one in getSeatStatusOverview + // rather than a shared helper: the two reports are free to diverge on what window + // makes sense for them, and a shared helper would couple them for ~20 lines. + let from = now; + let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW); + let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING'; + + const upcomingCount = await this.prisma.trainSchedule.count({ + where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, + }); + + if (upcomingCount === 0) { + const latest = await this.prisma.trainSchedule.findFirst({ + where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } }, + orderBy: { departureAt: 'desc' }, + select: { departureAt: true }, + }); + if (latest) { + direction = 'RECENT'; + to = latest.departureAt; + from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW); + } + } + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, + select: { + id: true, + departureAt: true, + isPackageOnly: true, + originStationId: true, + destinationStationId: true, + train: { select: { number: true } }, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + orderBy: { departureAt: 'asc' }, + take: OVERVIEW_MAX_SCHEDULES, + }); + + if (schedules.length === 0) { + return { + window: { from, to, days, direction, truncated: false }, + totals: { scheduleCount: 0, totalPassengers: 0, groupPassengers: 0 }, + byDay: [], + byNationality: [], + byCategory: [], + topRoutes: [], + schedules: [], + }; + } + + const scheduleIds = schedules.map((s) => s.id); + + // Same three-branch OR as the per-schedule report: own scheduleId, return leg, or a + // legacy null-scheduleId row reached through the booking's outbound schedule. + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { + scheduleId: { in: scheduleIds }, + booking: { status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES } }, + }, + { + leg: 2, + booking: { + returnScheduleId: { in: scheduleIds }, + status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES }, + }, + }, + { + scheduleId: null, + leg: 1, + booking: { + scheduleId: { in: scheduleIds }, + status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES }, + }, + }, + ], + }, + select: { + scheduleId: true, + leg: true, + bookingId: true, + passengerCategory: true, + passportCountry: true, + idDocumentType: true, + booking: { + select: { + scheduleId: true, + returnScheduleId: true, + originStationId: true, + destinationStationId: true, + }, + }, + }, + }); + + // Station names for the route pairs. Bookings that never recorded a station fall back + // to the schedule's own endpoints, the same fallback getOccupancyBySchedule applies. + const stationIds = [ + ...new Set( + [ + ...bookingSeats.flatMap((bs) => [ + bs.booking.originStationId, + bs.booking.destinationStationId, + ]), + ...schedules.flatMap((s) => [s.originStationId, s.destinationStationId]), + ].filter((id): id is string => Boolean(id)), + ), + ]; + const stations = stationIds.length + ? await this.prisma.station.findMany({ + where: { id: { in: stationIds } }, + select: { id: true, name: true }, + }) + : []; + const stationName = new Map(stations.map((s) => [s.id, s.name])); + + const scheduleById = new Map(schedules.map((s) => [s.id, s])); + const scheduleIdSet = new Set(scheduleIds); + + const passengersBySchedule = new Map(); + const nationalityCounts = new Map(); + const categoryCounts = new Map(); + const routeCounts = new Map(); + // A booking contributing more than one seat to the window is a group booking. + const seatsPerBooking = new Map(); + + for (const bs of bookingSeats) { + const scheduleId = + bs.scheduleId && scheduleIdSet.has(bs.scheduleId) + ? bs.scheduleId + : bs.leg === 2 + ? bs.booking.returnScheduleId + : bs.booking.scheduleId; + if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue; + + const schedule = scheduleById.get(scheduleId); + passengersBySchedule.set( + scheduleId, + (passengersBySchedule.get(scheduleId) ?? 0) + 1, + ); + seatsPerBooking.set(bs.bookingId, (seatsPerBooking.get(bs.bookingId) ?? 0) + 1); + + // Same derivation as getPassengerList, so the chart and the drill-down list agree + // on what a passenger's nationality is. + const nationality = bs.passportCountry + ? bs.passportCountry === 'Djibouti' + ? 'Djiboutian' + : bs.passportCountry + : bs.idDocumentType === 'NATIONAL_ID' + ? 'Ethiopian' + : 'Unknown'; + nationalityCounts.set(nationality, (nationalityCounts.get(nationality) ?? 0) + 1); + + const category = bs.passengerCategory ?? 'ADULT'; + categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); + + const originId = bs.booking.originStationId ?? schedule?.originStationId ?? null; + const destinationId = + bs.booking.destinationStationId ?? schedule?.destinationStationId ?? null; + if (originId && destinationId) { + const key = `${originId}|${destinationId}`; + const existing = routeCounts.get(key); + if (existing) { + existing.passengers += 1; + } else { + routeCounts.set(key, { + origin: stationName.get(originId) ?? originId, + destination: stationName.get(destinationId) ?? destinationId, + passengers: 1, + }); + } + } + } + + const groupPassengers = [...seatsPerBooking.values()] + .filter((count) => count > 1) + .reduce((sum, count) => sum + count, 0); + + const scheduleRows = schedules.map((s) => ({ + scheduleId: s.id, + trainNumber: s.train.number, + originStation: s.originStation.name, + destinationStation: s.destinationStation.name, + departureAt: s.departureAt, + isPackage: s.isPackageOnly, + passengers: passengersBySchedule.get(s.id) ?? 0, + })); + + const byDayMap = new Map(); + for (const row of scheduleRows) { + const date = row.departureAt.toISOString().slice(0, 10); + const bucket = byDayMap.get(date) ?? { date, scheduleCount: 0, passengers: 0 }; + bucket.scheduleCount += 1; + bucket.passengers += row.passengers; + byDayMap.set(date, bucket); + } + + const rank = (rows: T[]) => + rows.sort((a, b) => b.passengers - a.passengers); + + return { + window: { + from, + to, + days, + direction, + truncated: schedules.length === OVERVIEW_MAX_SCHEDULES, + }, + totals: { + scheduleCount: scheduleRows.length, + totalPassengers: scheduleRows.reduce((sum, r) => sum + r.passengers, 0), + groupPassengers, + }, + byDay: [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)), + byNationality: rank( + [...nationalityCounts.entries()].map(([nationality, passengers]) => ({ + nationality, + passengers, + })), + ), + byCategory: rank( + [...categoryCounts.entries()].map(([category, passengers]) => ({ + category, + passengers, + })), + ), + topRoutes: rank([...routeCounts.values()]).slice(0, TOP_ROUTES_LIMIT), + schedules: scheduleRows, + }; + } + async getPaymentDiscrepancyReport(params: { from?: string; to?: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 56ff8ddfe..8d72da2a2 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -3,6 +3,26 @@ import { PrismaService } from '../../common/prisma.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { snapshot } from '../../common/audit-snapshot'; + +const ROUTE_AUDIT_FIELDS = [ + 'code', + 'name', + 'description', + 'active', + 'effectiveFrom', + 'effectiveUntil', + 'checkinMinutesBefore', +] as const; +const ROUTE_STOP_AUDIT_FIELDS = [ + 'routeId', + 'stationId', + 'sequence', + 'distanceKm', + 'checkinMinutesBefore', + 'travelMinutesToStop', +] as const; import { parseEthiopianTime } from '../../common/utils/timezone.utils'; import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @@ -75,7 +95,12 @@ export class RoutesService { }, include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } }, }); - await this.auditService.log({ action: 'CREATE', entityType: 'Route', entityId: route.id, newData: { code: route.code, name: route.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Route, + entityId: route.id, + newData: snapshot(route, ROUTE_AUDIT_FIELDS), + }); return route; } @@ -165,11 +190,23 @@ export class RoutesService { } } - await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } }); - return this.prisma.route.findUnique({ + const updated = await this.prisma.route.findUnique({ where: { id }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Route, + entityId: id, + oldData: snapshot(route, ROUTE_AUDIT_FIELDS), + newData: { + ...snapshot(updated, ROUTE_AUDIT_FIELDS), + // Stop edits arrive as a full replacement, so record the resulting shape rather than + // every row — the RouteStop rows themselves are audited on the dedicated endpoints. + ...(dto.stops && dto.stops.length >= 2 ? { stopsReplaced: dto.stops.length } : {}), + }, + }); + return updated; } async deleteRoute(id: string, cascade = false) { @@ -254,7 +291,13 @@ export class RoutesService { } await this.prisma.route.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'Route', entityId: id, oldData: { code: route.code, name: route.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Route, + entityId: id, + oldData: snapshot(route, ROUTE_AUDIT_FIELDS), + newData: { cascade }, + }); return { deleted: true, id }; } @@ -276,7 +319,7 @@ export class RoutesService { const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } }); this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]); - return this.prisma.routeStop.create({ + const stop = await this.prisma.routeStop.create({ data: { routeId, stationId: dto.stationId, @@ -286,6 +329,13 @@ export class RoutesService { travelMinutesToStop: dto.travelMinutesToStop ?? null, }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.RouteStop, + entityId: stop.id, + newData: { ...snapshot(stop, ROUTE_STOP_AUDIT_FIELDS), stationName: station.name }, + }); + return stop; } async removeStop(routeId: string, sequence: number) { @@ -298,6 +348,12 @@ export class RoutesService { if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops'); await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.RouteStop, + entityId: stop.id, + oldData: snapshot(stop, ROUTE_STOP_AUDIT_FIELDS), + }); return { deleted: true, sequence }; } @@ -351,18 +407,48 @@ export class RoutesService { const positions = dto.coaches.map(c => c.positionNumber); if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values'); + const previous = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId }, + orderBy: { positionNumber: 'asc' }, + select: { coachId: true, positionNumber: true }, + }); + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); await this.prisma.routeCoachTemplate.createMany({ data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })), }); + // The template is replaced wholesale, so the audit row carries both compositions rather + // than one row per coach — a reader wants "what does this route run now vs. before". + await this.auditService.log({ + action: AUDIT_ACTIONS.ASSIGN, + entityType: AUDIT_ENTITIES.RouteCoachTemplate, + entityId: routeId, + oldData: { routeCode: route.code, coaches: previous }, + newData: { + routeCode: route.code, + coaches: dto.coaches.map(c => ({ coachId: c.coachId, positionNumber: c.positionNumber })), + }, + }); + return this.getRouteCoachTemplate(routeId); } async removeRouteCoachTemplate(routeId: string) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); + const previous = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId }, + orderBy: { positionNumber: 'asc' }, + select: { coachId: true, positionNumber: true }, + }); await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UNASSIGN, + entityType: AUDIT_ENTITIES.RouteCoachTemplate, + entityId: routeId, + oldData: { routeCode: route.code, coaches: previous }, + }); return { deleted: true, routeId }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules-audit.spec.ts b/apps/edr-passenger-api/src/modules/schedules/schedules-audit.spec.ts new file mode 100644 index 000000000..0bd129dbc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/schedules/schedules-audit.spec.ts @@ -0,0 +1,199 @@ +import { SchedulesService } from './schedules.service'; + +/** + * Master-data coverage, using schedules as the representative entity. + * + * `updateScheduleStatus` was a one-line Prisma update with no audit call at all, so "who + * cancelled this schedule" had no answer. Fare-rule edits were similar: the previous price was + * read and then discarded, leaving an UPDATE row that didn't say what changed. + */ +describe('SchedulesService — audit', () => { + const SCHEDULE_ID = 'sched-1'; + + let prisma: Record; + let audit: { log: jest.Mock }; + let service: SchedulesService; + + const scheduleRow = (over: Record = {}) => ({ + id: SCHEDULE_ID, + trainId: 'train-1', + routeId: 'route-1', + originStationId: 'station-a', + destinationStationId: 'station-b', + departureAt: new Date('2026-09-01T06:00:00.000Z'), + arrivalAt: new Date('2026-09-01T18:00:00.000Z'), + durationMinutes: 720, + stopsCount: 3, + status: 'SCHEDULED', + ...over, + }); + + const build = (over: Record = {}) => { + const schedule = scheduleRow(over); + prisma = { + trainSchedule: { + findUnique: jest.fn().mockResolvedValue(schedule), + update: jest.fn(async ({ data }: any) => ({ ...schedule, ...data })), + }, + fareRule: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn().mockResolvedValue({}), + }, + routeFareRule: { + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn().mockResolvedValue({}), + }, + segmentFareRule: { findUnique: jest.fn(), update: jest.fn(), delete: jest.fn() }, + }; + audit = { log: jest.fn().mockResolvedValue(undefined) }; + + service = new SchedulesService( + prisma as any, + {} as any, // routesService + {} as any, // fareEngine + audit as any, + { updateLiveStatus: jest.fn() } as any, // liveService + ); + return schedule; + }; + + const rows = () => audit.log.mock.calls.map((c) => c[0]); + + describe('updateScheduleStatus', () => { + it('records one STATUS_CHANGE with the status it moved from and to', async () => { + build(); + await service.updateScheduleStatus(SCHEDULE_ID, { status: 'BOARDING' } as any); + + expect(rows()).toHaveLength(1); + expect(rows()[0]).toMatchObject({ + action: 'STATUS_CHANGE', + entityType: 'Schedule', + entityId: SCHEDULE_ID, + oldData: { status: 'SCHEDULED' }, + }); + expect(rows()[0].newData.status).toBe('BOARDING'); + }); + + it('uses CANCEL for a cancellation so it is not lost among ordinary updates', async () => { + build(); + await service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any); + + expect(rows()[0]).toMatchObject({ + action: 'CANCEL', + entityType: 'Schedule', + entityId: SCHEDULE_ID, + oldData: { status: 'SCHEDULED' }, + }); + }); + + it('records nothing when the schedule does not exist', async () => { + build(); + prisma.trainSchedule.findUnique.mockResolvedValue(null); + + await expect( + service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any), + ).rejects.toThrow(); + expect(audit.log).not.toHaveBeenCalled(); + }); + + it('records nothing when the write itself fails', async () => { + build(); + prisma.trainSchedule.update.mockRejectedValue(new Error('db down')); + + await expect( + service.updateScheduleStatus(SCHEDULE_ID, { status: 'CANCELLED' } as any), + ).rejects.toThrow(); + expect(audit.log).not.toHaveBeenCalled(); + }); + + it('leaves the actor to AuditService', async () => { + build(); + await service.updateScheduleStatus(SCHEDULE_ID, { status: 'DELAYED' } as any); + expect(rows()[0].userId).toBeUndefined(); + }); + }); + + describe('fare rules', () => { + it('records the previous price on an update, not just the new one', async () => { + build(); + const before = { + id: 'fr-1', + tripId: SCHEDULE_ID, + seatClassId: 'sc-1', + baseFareMinor: 50000, + currency: 'ETB', + nationality: null, + validFrom: new Date('2026-01-01'), + validUntil: null, + }; + prisma.fareRule.findUnique.mockResolvedValue(before); + prisma.fareRule.update.mockResolvedValue({ ...before, baseFareMinor: 65000 }); + + await service.updateFareRule('fr-1', { baseFareMinor: 65000 } as any); + + const row = rows()[0]; + expect(row).toMatchObject({ action: 'UPDATE', entityType: 'FareRule', entityId: 'fr-1' }); + expect(row.oldData).toMatchObject({ baseFareMinor: 50000 }); + expect(row.newData).toMatchObject({ baseFareMinor: 65000 }); + }); + + it('records what a deleted fare rule was worth', async () => { + build(); + prisma.fareRule.findUnique.mockResolvedValue({ + id: 'fr-1', + tripId: SCHEDULE_ID, + seatClassId: 'sc-1', + baseFareMinor: 50000, + currency: 'ETB', + }); + + await service.deleteFareRule('fr-1'); + + expect(rows()[0]).toMatchObject({ action: 'DELETE', entityType: 'FareRule', entityId: 'fr-1' }); + expect(rows()[0].oldData).toMatchObject({ baseFareMinor: 50000 }); + }); + + it('records a route fare-rule price change that previously left no trail', async () => { + build(); + const before = { + id: 'rfr-1', + routeId: 'route-1', + seatClassId: 'sc-1', + passengerCategory: 'ADULT', + baseFareMinor: 40000, + surchargeMinor: 0, + validFrom: new Date('2026-01-01'), + validUntil: null, + }; + prisma.routeFareRule.findUnique.mockResolvedValue(before); + prisma.routeFareRule.update.mockResolvedValue({ ...before, baseFareMinor: 45000 }); + + await service.updateRouteFareRule('rfr-1', { baseFareMinor: 45000 }); + + expect(rows()[0]).toMatchObject({ action: 'UPDATE', entityType: 'RouteFareRule' }); + expect(rows()[0].oldData).toMatchObject({ baseFareMinor: 40000 }); + expect(rows()[0].newData).toMatchObject({ baseFareMinor: 45000 }); + }); + }); + + describe('reads', () => { + it('writes nothing when listing schedules', async () => { + build(); + prisma.trainSchedule.findMany = jest.fn().mockResolvedValue([]); + + await service.listSchedules({} as any); + expect(audit.log).not.toHaveBeenCalled(); + }); + + it('writes nothing when listing fare rules', async () => { + build(); + prisma.fareRule.findMany = jest.fn().mockResolvedValue([]); + + await service.getFareRules(SCHEDULE_ID); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); +}); 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 7680d8f9c..ab795598b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -6,9 +6,59 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { snapshot } from '../../common/audit-snapshot'; import { LiveService } from '../live/live.service'; import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; +const SCHEDULE_AUDIT_FIELDS = [ + 'trainId', + 'routeId', + 'originStationId', + 'destinationStationId', + 'departureAt', + 'arrivalAt', + 'durationMinutes', + 'stopsCount', + 'status', +] as const; +const FARE_RULE_AUDIT_FIELDS = [ + 'tripId', + 'seatClassId', + 'baseFareMinor', + 'currency', + 'nationality', + 'validFrom', + 'validUntil', +] as const; +const SEGMENT_FARE_AUDIT_FIELDS = [ + 'routeId', + 'seatClassId', + 'originStopSequence', + 'destinationStopSequence', + 'baseFareMinor', + 'currency', + 'validFrom', + 'validUntil', +] as const; +const ROUTE_FARE_AUDIT_FIELDS = [ + 'routeId', + 'seatClassId', + 'passengerCategory', + 'baseFareMinor', + 'surchargeMinor', + 'validFrom', + 'validUntil', +] as const; +const STOP_TIME_AUDIT_FIELDS = [ + 'scheduleId', + 'stationId', + 'sequence', + 'plannedArrivalAt', + 'plannedDepartureAt', + 'status', +] as const; + @Injectable() export class SchedulesService { private readonly logger = new Logger(SchedulesService.name); @@ -102,6 +152,24 @@ export class SchedulesService { currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000); } + // One row for the whole sweep, not one per schedule — the operator performed a single + // action and the created ids are the interesting part. + await this.auditService.log({ + action: AUDIT_ACTIONS.BULK_CREATE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: dto.routeId, + newData: { + routeId: dto.routeId, + trainId: dto.trainId, + startDateTime: dto.startDateTime, + forNextDays: dto.forNextDays, + repeatEveryDays: dto.repeatEveryDays, + schedulesCreated: scheduleCount, + scheduleIds, + errorCount: errors.length, + }, + }); + return { schedulesCreated: scheduleCount, errors, scheduleIds }; } @@ -234,7 +302,12 @@ export class SchedulesService { } const result = await this.getSchedule(schedule.id); - await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: schedule.id, + newData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), + }); return result; } @@ -352,12 +425,37 @@ export class SchedulesService { await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap); const result = await this.getSchedule(id); - await this.auditService.log({ action: 'UPDATE', entityType: 'Schedule', entityId: id, newData: { trainId: dto.trainId, departureAt: dep } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: id, + oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), + newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS), + }); return result; } async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) { - return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } }); + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const updated = await this.prisma.trainSchedule.update({ + where: { id }, + data: { status: dto.status }, + }); + + // CANCELLED is the one transition an operator is asked to justify after the fact, so it + // gets its own verb; everything else is a plain status move. + await this.auditService.log({ + action: + dto.status === 'CANCELLED' ? AUDIT_ACTIONS.CANCEL : AUDIT_ACTIONS.STATUS_CHANGE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: id, + oldData: { status: schedule.status }, + newData: { status: updated.status, departureAt: updated.departureAt.toISOString() }, + }); + + return updated; } async deleteSchedule(id: string, cascade = false) { @@ -438,7 +536,13 @@ export class SchedulesService { await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } }); } await this.prisma.trainSchedule.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'Schedule', entityId: id }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: id, + oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), + newData: { cascade, bookingsAffected: (schedule as any)._count?.bookings ?? 0 }, + }); return { deleted: true, id }; } @@ -456,7 +560,7 @@ export class SchedulesService { }); if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on schedule`); - return this.prisma.tripStopTime.update({ + const updated = await this.prisma.tripStopTime.update({ where: { scheduleId_sequence: { scheduleId, sequence } }, data: { plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined, @@ -465,6 +569,14 @@ export class SchedulesService { }, include: { station: true }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: scheduleId, + oldData: snapshot(stop, STOP_TIME_AUDIT_FIELDS), + newData: snapshot(updated, STOP_TIME_AUDIT_FIELDS), + }); + return updated; } /** @@ -522,10 +634,19 @@ export class SchedulesService { await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes }); await this.auditService.log({ - action: 'UPDATE', - entityType: 'Schedule', + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Schedule, entityId: scheduleId, - newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes }, + oldData: { + delayMinutes: currentLive?.delayMinutes ?? 0, + departureAt: schedule.departureAt.toISOString(), + }, + newData: { + delayMinutes: dto.delayMinutes, + fromSequence: dto.fromSequence, + accumulatedDelayMinutes, + stopsShifted: stopsToShift.length, + }, }); return this.getSchedule(scheduleId); @@ -547,7 +668,12 @@ export class SchedulesService { const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now; const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null; - return this.prisma.$transaction(async (tx) => { + const superseded = await this.prisma.fareRule.findFirst({ + where: { tripId: scheduleId, seatClassId, validUntil: null }, + orderBy: { validFrom: 'desc' }, + }); + + const created = await this.prisma.$transaction(async (tx) => { await tx.fareRule.updateMany({ where: { tripId: scheduleId, seatClassId, validUntil: null }, data: { validUntil: now }, @@ -557,11 +683,27 @@ export class SchedulesService { include: { seatClass: true }, }); }); + + // A schedule fare is versioned rather than edited, so the audit row pairs the rule that was + // closed off with the one that replaced it — otherwise the price change is invisible. + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.ScheduleFare, + entityId: created.id, + oldData: snapshot(superseded, FARE_RULE_AUDIT_FIELDS), + newData: { + ...snapshot(created, FARE_RULE_AUDIT_FIELDS), + scheduleId, + seatClassName: seatClass.name, + }, + }); + + return created; } - createFareRule(dto: CreateFareRuleDto) { + async createFareRule(dto: CreateFareRuleDto) { const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; - const result = this.prisma.fareRule.create({ + const rule = await this.prisma.fareRule.create({ data: { ...rest, tripId: scheduleId, @@ -571,8 +713,15 @@ export class SchedulesService { }, include: { seatClass: true }, }); - result.then(r => this.auditService.log({ action: 'CREATE', entityType: 'FareRule', entityId: r.id, newData: { seatClassId: r.seatClassId, baseFareMinor: r.baseFareMinor } })); - return result; + // Awaited, not a floating .then(): an unhandled rejection there could outlive the response, + // and the row could land after the caller had already moved on. + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.FareRule, + entityId: rule.id, + newData: snapshot(rule, FARE_RULE_AUDIT_FIELDS), + }); + return rule; } async updateFareRule(id: string, dto: Partial) { @@ -580,7 +729,7 @@ export class SchedulesService { if (!existing) throw new NotFoundException('Fare rule not found'); const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; - return this.prisma.fareRule.update({ + const updated = await this.prisma.fareRule.update({ where: { id }, data: { ...rest, @@ -591,19 +740,32 @@ export class SchedulesService { }, include: { seatClass: true }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.FareRule, + entityId: id, + oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS), + newData: snapshot(updated, FARE_RULE_AUDIT_FIELDS), + }); + return updated; } async deleteFareRule(id: string) { const existing = await this.prisma.fareRule.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Fare rule not found'); await this.prisma.fareRule.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'FareRule', entityId: id }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.FareRule, + entityId: id, + oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS), + }); return { deleted: true, id }; } - createSegmentFareRule(dto: any) { + async createSegmentFareRule(dto: any) { const { validFrom, validUntil, passengerCategory, ...rest } = dto; - return this.prisma.segmentFareRule.create({ + const rule = await this.prisma.segmentFareRule.create({ data: { ...rest, validFrom: parseEthiopianTime(validFrom), @@ -611,6 +773,13 @@ export class SchedulesService { }, include: { seatClass: true, route: true }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.SegmentFareRule, + entityId: rule.id, + newData: snapshot(rule, SEGMENT_FARE_AUDIT_FIELDS), + }); + return rule; } getSegmentFares(routeId: string) { @@ -621,13 +790,25 @@ export class SchedulesService { }); } - deleteSegmentFareRule(id: string) { - return this.prisma.segmentFareRule.delete({ where: { id } }); + async deleteSegmentFareRule(id: string) { + const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Segment fare rule not found'); + const deleted = await this.prisma.segmentFareRule.delete({ where: { id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.SegmentFareRule, + entityId: id, + oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS), + }); + return deleted; } - updateSegmentFareRule(id: string, dto: any) { + async updateSegmentFareRule(id: string, dto: any) { + const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Segment fare rule not found'); + const { validFrom, validUntil, passengerCategory, ...rest } = dto; - return this.prisma.segmentFareRule.update({ + const updated = await this.prisma.segmentFareRule.update({ where: { id }, data: { ...rest, @@ -636,6 +817,14 @@ export class SchedulesService { }, include: { seatClass: true, route: true }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.SegmentFareRule, + entityId: id, + oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS), + newData: snapshot(updated, SEGMENT_FARE_AUDIT_FIELDS), + }); + return updated; } async getFareRules(scheduleId?: string) { @@ -700,6 +889,15 @@ export class SchedulesService { } } + // One row for the sweep: the operator pressed sync once, and every fare it rewrote is + // reconstructable from the FareRule versions it created. + await this.auditService.log({ + action: AUDIT_ACTIONS.SYNC, + entityType: AUDIT_ENTITIES.ScheduleFare, + entityId: scheduleId, + newData: { scheduleId, synced, errorCount: errors.length }, + }); + return { synced, errors }; } @@ -718,6 +916,16 @@ export class SchedulesService { ); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap); + await this.auditService.log({ + action: AUDIT_ACTIONS.BULK_UPDATE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: scheduleId, + newData: { + recalculatedStopTimes: true, + routeId: schedule.routeId, + stopCount: plannedTimes.length, + }, + }); return { recalculated: true, scheduleId, stopCount: plannedTimes.length }; } @@ -731,6 +939,12 @@ export class SchedulesService { const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE'); if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`); + const previous = await this.prisma.coachAssignment.findMany({ + where: { scheduleId }, + orderBy: { positionNumber: 'asc' }, + select: { coachId: true, positionNumber: true }, + }); + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); const data = coaches.map((c) => ({ @@ -741,6 +955,20 @@ export class SchedulesService { })); await this.prisma.coachAssignment.createMany({ data }); + + // Assignment is a wholesale replacement, so both compositions go on one row rather than a + // delete row per coach followed by a create row per coach. + await this.auditService.log({ + action: AUDIT_ACTIONS.ASSIGN, + entityType: AUDIT_ENTITIES.CoachAssignment, + entityId: scheduleId, + oldData: { scheduleId, coaches: previous }, + newData: { + scheduleId, + coaches: coaches.map((c) => ({ coachId: c.coachId, positionNumber: c.positionNumber })), + }, + }); + return { message: 'Coaches assigned successfully', count: coaches.length }; } @@ -795,19 +1023,55 @@ export class SchedulesService { if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { + // Logs its own ASSIGN row; this method only audits the schedule's own fields, so the + // two rows describe two facts rather than double-reporting one. await this.assignCoaches(id, dto.coaches); } else { + const cleared = await this.prisma.coachAssignment.findMany({ + where: { scheduleId: id }, + select: { coachId: true, positionNumber: true }, + }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UNASSIGN, + entityType: AUDIT_ENTITIES.CoachAssignment, + entityId: id, + oldData: { scheduleId: id, coaches: cleared }, + newData: { scheduleId: id, coaches: [] }, + }); } } - return this.getSchedule(id); + const result = await this.getSchedule(id); + + if (Object.keys(updateData).length > 0) { + const statusChanged = dto.status !== undefined && dto.status !== schedule.status; + await this.auditService.log({ + action: statusChanged + ? dto.status === 'CANCELLED' + ? AUDIT_ACTIONS.CANCEL + : AUDIT_ACTIONS.STATUS_CHANGE + : AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Schedule, + entityId: id, + oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), + newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS), + }); + } + + return result; } async removeCoachAssignment(scheduleId: string, coachId: string) { const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } }); if (!assignment) throw new NotFoundException('Coach assignment not found'); await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UNASSIGN, + entityType: AUDIT_ENTITIES.CoachAssignment, + entityId: assignment.id, + oldData: { scheduleId, coachId, positionNumber: assignment.positionNumber }, + }); return { message: 'Coach assignment removed' }; } @@ -846,14 +1110,23 @@ export class SchedulesService { }, include: { seatClass: true, route: true }, }); - await this.auditService.log({ action: 'CREATE', entityType: 'RouteFareRule', entityId: rule.id, newData: { routeId: dto.routeId, seatClassId: dto.seatClassId, baseFareMinor: dto.baseFareMinor } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.RouteFareRule, + entityId: rule.id, + newData: { + ...snapshot(rule, ROUTE_FARE_AUDIT_FIELDS), + routeCode: route.code, + seatClassName: seatClass.name, + }, + }); return rule; } async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) { const rule = await this.prisma.routeFareRule.findUnique({ where: { id } }); if (!rule) throw new NotFoundException('Route fare rule not found'); - return this.prisma.routeFareRule.update({ + const updated = await this.prisma.routeFareRule.update({ where: { id }, data: { ...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }), @@ -863,13 +1136,26 @@ export class SchedulesService { }, include: { seatClass: true, route: true }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.RouteFareRule, + entityId: id, + oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS), + newData: snapshot(updated, ROUTE_FARE_AUDIT_FIELDS), + }); + return updated; } async deleteRouteFareRule(id: string) { const rule = await this.prisma.routeFareRule.findUnique({ where: { id } }); if (!rule) throw new NotFoundException('Route fare rule not found'); await this.prisma.routeFareRule.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'RouteFareRule', entityId: id }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.RouteFareRule, + entityId: id, + oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS), + }); return { deleted: true, id }; } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index f33ecac64..9f103600f 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -2,6 +2,22 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common import { PrismaService } from '../../common/prisma.service'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { snapshot } from '../../common/audit-snapshot'; + +/** + * Seat classes carry the per-km tariff rate, so `baseFareMinor` is the field an auditor is + * actually chasing — logging only the name made a price change indistinguishable from a rename. + */ +const SEAT_CLASS_AUDIT_FIELDS = [ + 'coachTypeId', + 'name', + 'description', + 'baseFareMinor', + 'premiumMinor', + 'insuranceFeeMinor', + 'isActive', +] as const; @Injectable() export class SeatClassesService { @@ -30,7 +46,13 @@ export class SeatClassesService { ...(basePrice !== undefined && { baseFareMinor: basePrice }), }; const updated = await this.prisma.seatClass.update({ where: { id }, data }); - await this.auditService.log({ action: 'UPDATE', entityType: 'SeatClass', entityId: id, newData: { name: updated.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.SeatClass, + entityId: id, + oldData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS), + newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS), + }); return updated; } @@ -42,7 +64,12 @@ export class SeatClassesService { ...(basePrice !== undefined && { baseFareMinor: basePrice }), }; const sc = await this.prisma.seatClass.create({ data }); - await this.auditService.log({ action: 'CREATE', entityType: 'SeatClass', entityId: sc.id, newData: { name: sc.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.SeatClass, + entityId: sc.id, + newData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS), + }); return sc; } catch (e: any) { if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); @@ -76,7 +103,13 @@ export class SeatClassesService { } const deleted = await this.prisma.seatClass.delete({ where: { id } }); - await this.auditService.log({ action: 'DELETE', entityType: 'SeatClass', entityId: id, oldData: { name: sc.name } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.SeatClass, + entityId: id, + oldData: snapshot(sc, SEAT_CLASS_AUDIT_FIELDS), + newData: { cascade, fareRulesDeleted: cascade ? totalFareRules : 0 }, + }); return deleted; } } 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 83dc6aea4..00aff331b 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { SmsClientService } from '../notifications/sms-client.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @@ -958,6 +959,13 @@ export class SeatsService { } } + await this.auditService.log({ + action: AUDIT_ACTIONS.IMPORT, + entityType: AUDIT_ENTITIES.Seat, + entityId: scheduleId, + newData: { scheduleId, rows: lines.length, imported, errorCount: errors.length }, + }); + return { imported, errors: errors.slice(0, 10) }; } @@ -991,10 +999,11 @@ export class SeatsService { }); } await this.auditService.log({ - action: 'UPDATE', - entityType: 'Seat', + action: AUDIT_ACTIONS.BLOCK, + entityType: AUDIT_ENTITIES.Seat, entityId: seatId, - newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy }, + oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId }, + newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy, blockedByName }, }); return { blocked: true, seatId, reason, reasonCategory, scheduleId, blockedBy, blockedByName }; } @@ -1009,7 +1018,13 @@ export class SeatsService { await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } }); await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } }); } - await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.UNBLOCK, + entityType: AUDIT_ENTITIES.Seat, + entityId: seatId, + oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId }, + newData: { status: 'AVAILABLE', scheduleId }, + }); return { unblocked: true, seatId, scheduleId }; } @@ -1027,6 +1042,13 @@ export class SeatsService { blockedByName: actor?.name ?? 'System', }, }); + await this.auditService.log({ + action: AUDIT_ACTIONS.STATUS_CHANGE, + entityType: AUDIT_ENTITIES.Seat, + entityId: seatId, + oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId }, + newData: { status: 'UNDER_MAINTENANCE', reason, blockedBy: actor?.id ?? 'SYSTEM' }, + }); return { maintenance: true, seatId, reason }; } @@ -1035,6 +1057,13 @@ export class SeatsService { if (!seat) throw new NotFoundException('Seat not found'); await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } }); await this.prisma.seatBlock.deleteMany({ where: { seatId } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.STATUS_CHANGE, + entityType: AUDIT_ENTITIES.Seat, + entityId: seatId, + oldData: { status: seat.status, seatNumber: seat.seatNumber, coachId: seat.coachId }, + newData: { status: 'AVAILABLE' }, + }); return { maintenance: false, seatId }; } @@ -1050,7 +1079,12 @@ export class SeatsService { }); await this.renumberCoachSeats(seat.coachId); - await this.auditService.log({ action: 'DELETE', entityType: 'Seat', entityId: seatId, oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Seat, + entityId: seatId, + oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId, status: seat.status }, + }); return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; } @@ -1066,6 +1100,13 @@ export class SeatsService { await this.renumberCoachSeats(seat.coachId); const restored = await this.prisma.seat.findUnique({ where: { id: seatId } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.RESTORE, + entityType: AUDIT_ENTITIES.Seat, + entityId: seatId, + oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId }, + newData: { seatNumber: restored?.seatNumber, coachId: seat.coachId }, + }); return { restored: true, seatId, seatNumber: restored?.seatNumber }; } @@ -1641,6 +1682,24 @@ export class SeatsService { }); } + // `results` carries contactPhone for the SMS step — the audit row keeps only the seat move + // itself, so a passenger's number never lands in a log retained for a year. + await this.auditService.log({ + action: AUDIT_ACTIONS.BULK_UPDATE, + entityType: AUDIT_ENTITIES.Seat, + entityId: coachIds[0], + newData: { + coachIds, + resolved: results.length, + unresolved: unresolved.length, + moves: results.map((r) => ({ + bookingRef: r.bookingRef, + oldSeatNumber: r.oldSeatNumber, + newSeatNumber: r.newSeatNumber, + })), + }, + }); + return { resolved: results.length, unresolved: unresolved.length, diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index ff40a30ab..4d6d87273 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -1,10 +1,23 @@ -import { Injectable, NotFoundException, Inject, Optional, BadRequestException } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { snapshot } from '../../common/audit-snapshot'; import { CreateStationDto } from './stations.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; +/** The station fields worth carrying into an audit row — identity, placement, and status. */ +const STATION_AUDIT_FIELDS = [ + 'code', + 'name', + 'city', + 'countryCode', + 'lat', + 'lng', + 'sequence', + 'isOperational', +] as const; + interface StationFilters { search?: string; country?: string; @@ -16,7 +29,6 @@ export class StationsService { constructor( private prisma: PrismaService, private auditService: AuditService, - @Optional() @Inject(REQUEST) private request?: any, ) {} findAll(filters: StationFilters = {}) { @@ -61,11 +73,10 @@ export class StationsService { }); await this.auditService.log({ - userId: this.request?.user?.id, - action: 'CREATE', - entityType: 'Station', + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Station, entityId: station.id, - newData: station, + newData: snapshot(station, STATION_AUDIT_FIELDS), }); return station; @@ -85,12 +96,11 @@ export class StationsService { }); await this.auditService.log({ - userId: this.request?.user?.id, - action: 'UPDATE', - entityType: 'Station', + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Station, entityId: id, - oldData: oldStation, - newData: updatedStation, + oldData: snapshot(oldStation, STATION_AUDIT_FIELDS), + newData: snapshot(updatedStation, STATION_AUDIT_FIELDS), }); return updatedStation; @@ -139,11 +149,11 @@ export class StationsService { const deleted = await this.prisma.station.delete({ where: { id } }); await this.auditService.log({ - userId: this.request?.user?.id, - action: 'DELETE', - entityType: 'Station', + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.Station, entityId: id, - oldData: station, + oldData: snapshot(station, STATION_AUDIT_FIELDS), + newData: { cascade }, }); return deleted; diff --git a/apps/edr-passenger-api/src/modules/tickets/boarding-audit.spec.ts b/apps/edr-passenger-api/src/modules/tickets/boarding-audit.spec.ts new file mode 100644 index 000000000..e70b8d3ca --- /dev/null +++ b/apps/edr-passenger-api/src/modules/tickets/boarding-audit.spec.ts @@ -0,0 +1,231 @@ +import { TicketsService } from './tickets.service'; + +/** + * "USER X boarded TICKET Y" has to be answerable from AuditLog alone. + * + * Before this, boarding wrote `action: 'VERIFY'` with no actor and no previous status, and the + * only name on the row was `validatorId` — a request-body field, so whoever scanned could put + * anyone's id in the trail. These pin: one row per boarding (not one per layer), the actor + * coming from the session, and the ticket's before/after status both being recorded. + */ +describe('TicketsService — boarding audit', () => { + const ACTOR = { id: 'iam-staff-1', name: 'Abebe Kebede', phone: '+251911223344' }; + const TICKET_ID = 'ticket-1'; + const BOOKING_ID = 'booking-1'; + const BOOKING_REF = 'EDR-0001'; + + let prisma: Record; + let audit: { log: jest.Mock }; + let service: TicketsService; + + const build = ( + opts: { + bookingType?: string; + ticket?: Record; + booking?: Record; + approvedLegs?: string[]; + } = {}, + ) => { + const ticket = { + id: TICKET_ID, + bookingId: BOOKING_ID, + bookingRef: BOOKING_REF, + seatId: 'seat-9', + leg: 1, + status: 'ACTIVE', + validatedAt: null, + boardedAt: null, + ...opts.ticket, + }; + // Departure an hour out, so scanAndBoard's boarding window is open. + const departureAt = new Date(Date.now() + 60 * 60 * 1000); + const booking = { + id: BOOKING_ID, + bookingRef: BOOKING_REF, + bookingType: opts.bookingType ?? 'ONE_WAY', + status: 'CONFIRMED', + originStationId: 'station-a', + destinationStationId: 'station-b', + outboundBoardedAt: null, + returnBoardedAt: null, + tickets: [ticket], + seats: [], + schedule: { + id: 'sched-1', + departureAt, + arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000), + originStationId: 'station-a', + destinationStationId: 'station-b', + originStation: { id: 'station-a', name: 'Furi Labu' }, + destinationStation: { id: 'station-b', name: 'Dire Dawa' }, + train: { id: 'train-1', number: 'T1' }, + stopTimes: [], + }, + returnSchedule: null, + ...opts.booking, + }; + + prisma = { + ticket: { + findUnique: jest.fn().mockResolvedValue(ticket), + findFirst: jest.fn().mockResolvedValue(ticket), + update: jest.fn().mockResolvedValue(ticket), + }, + booking: { + findUnique: jest.fn().mockResolvedValue(booking), + update: jest.fn().mockResolvedValue(booking), + }, + gateValidationLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest + .fn() + .mockResolvedValue((opts.approvedLegs ?? []).map((leg) => ({ leg, status: 'APPROVED' }))), + }, + }; + audit = { log: jest.fn().mockResolvedValue(undefined) }; + + // Constructor order: prisma, notifications, systemConfig, auditService, dataSource. + service = new TicketsService( + prisma as any, + { sendSms: jest.fn(), sendEmail: jest.fn() } as any, + { get: jest.fn().mockResolvedValue(null), getNumber: jest.fn().mockResolvedValue(4) } as any, + audit as any, + {} as any, + ); + return { ticket, booking }; + }; + + const rows = () => audit.log.mock.calls.map((c) => c[0]); + const boardRows = () => rows().filter((r) => r.action === 'BOARD'); + + describe('a successful one-way boarding', () => { + it('writes exactly one BOARD row', async () => { + build(); + await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR); + expect(boardRows()).toHaveLength(1); + }); + + it('identifies the ticket and the booking it belongs to', async () => { + build(); + await service.validate(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', undefined, ACTOR); + + expect(boardRows()[0]).toMatchObject({ + action: 'BOARD', + entityType: 'Ticket', + entityId: TICKET_ID, + }); + expect(boardRows()[0].newData).toMatchObject({ + bookingRef: BOOKING_REF, + bookingId: BOOKING_ID, + seatId: 'seat-9', + }); + }); + + it('records the status the ticket moved from and to', async () => { + build(); + await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR); + + const row = boardRows()[0]; + expect(row.oldData).toMatchObject({ status: 'ACTIVE', validatedAt: null }); + expect(row.newData.status).toBe('USED'); + expect(row.newData.boardedAt).toEqual(expect.any(String)); + }); + + it('leaves the actor to AuditService rather than passing a client-supplied id', async () => { + build(); + await service.validate(BOOKING_REF, 'anyone-can-type-this', undefined, undefined, ACTOR); + + // `userId` is never set at the call site — AuditService reads the guarded session, so the + // body value below can only ever appear as descriptive context. + expect(boardRows()[0].userId).toBeUndefined(); + expect(boardRows()[0].newData.validatorId).toBe('anyone-can-type-this'); + }); + + it('names the authenticated user on the gate log when no validatorId is sent', async () => { + build(); + await service.validate(BOOKING_REF, '', undefined, undefined, ACTOR); + + // Previously fell straight through to the anonymous 'BACKOFFICE' literal. + expect(prisma.gateValidationLog.create.mock.calls[0][0].data.validatorId).toBe(ACTOR.id); + }); + }); + + describe('scanAndBoard', () => { + it('produces one row, not one per layer', async () => { + build(); + // scanAndBoard delegates to validate(); logging in both would double every boarding. + await service.scanAndBoard(BOOKING_REF, 'GATE-1', 'MOBILE-GATE', ACTOR); + expect(boardRows()).toHaveLength(1); + }); + }); + + describe('a refused boarding', () => { + it('records BOARD_DENIED with the reason on a round-trip leg already used', async () => { + build({ bookingType: 'ROUND_TRIP', booking: { outboundBoardedAt: new Date() } }); + + await expect( + service.validate(BOOKING_REF, 'GATE-1', undefined, 'OUTBOUND', ACTOR), + ).rejects.toThrow(); + + const denied = rows().filter((r) => r.action === 'BOARD_DENIED'); + expect(denied).toHaveLength(1); + expect(denied[0]).toMatchObject({ entityType: 'Ticket', entityId: TICKET_ID }); + expect(denied[0].newData).toMatchObject({ + result: 'REJECTED', + reason: 'OUTBOUND_ALREADY_USED', + bookingRef: BOOKING_REF, + }); + }); + + it('writes no BOARD row when the boarding was refused', async () => { + build({ bookingType: 'TRANSIT', approvedLegs: ['LEG1'] }); + + await expect( + service.validate(BOOKING_REF, 'GATE-1', undefined, 'LEG1', ACTOR), + ).rejects.toThrow(); + + expect(boardRows()).toHaveLength(0); + }); + }); + + describe('reads', () => { + it('writes nothing when simply fetching a ticket', async () => { + build(); + await service.getByRef(BOOKING_REF).catch(() => undefined); + expect(audit.log).not.toHaveBeenCalled(); + }); + }); + + describe('failed operations', () => { + it('records nothing when the booking does not exist', async () => { + build(); + prisma.booking.findUnique.mockResolvedValue(null); + + await expect( + service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR), + ).rejects.toThrow(); + expect(audit.log).not.toHaveBeenCalled(); + }); + + it('records nothing when the ticket write itself fails', async () => { + build(); + prisma.ticket.update.mockRejectedValue(new Error('db down')); + + await expect( + service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR), + ).rejects.toThrow(); + expect(boardRows()).toHaveLength(0); + }); + }); + + describe('sensitive data', () => { + it('keeps the QR payload and passenger name off the row', async () => { + build({ ticket: { qrPayload: 'QR-SECRET', passengerName: 'Abebe Kebede' } }); + await service.validate(BOOKING_REF, 'GATE-1', undefined, undefined, ACTOR); + + const serialized = JSON.stringify(boardRows()[0]); + expect(serialized).not.toContain('QR-SECRET'); + expect(serialized).not.toContain('Abebe Kebede'); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 3467706de..a835a3e67 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { resolveActingUser } from '../../common/acting-user'; @ApiTags('Tickets') @Controller('tickets') @@ -135,10 +136,13 @@ export class TicketsController { }) scanAndBoard( @Param('qrCodeOrRef') qrCodeOrRef: string, + @Req() req: any, @Body('validatorId') validatorId: string, @Body('gateId') gateId?: string, ) { - return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId); + // `validatorId` still labels the gate/agent on the gate log; who is accountable for the + // boarding comes from the JWT, which the body cannot influence. + return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId, resolveActingUser(req)); } @Post(':bookingRef/validate') @@ -165,11 +169,12 @@ export class TicketsController { }) validate( @Param('bookingRef') ref: string, + @Req() req: any, @Body('validatorId') validatorId: string, @Body('gateId') gateId?: string, @Body('leg') leg?: string, ) { - return this.service.validate(ref, validatorId, gateId, leg); + return this.service.validate(ref, validatorId, gateId, leg, resolveActingUser(req)); } @Get(':ticketId/validation-logs') @@ -216,8 +221,8 @@ export class TicketsController { }, }, }) - validateOfflineBatch(@Body() body: { validations: any[] }) { - return this.service.validateOfflineBatch(body.validations); + validateOfflineBatch(@Body() body: { validations: any[] }, @Req() req: any) { + return this.service.validateOfflineBatch(body.validations, resolveActingUser(req)); } @Delete(':id') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 6a45eeb69..ae2d0e034 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -5,6 +5,8 @@ import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { ActingUser } from '../../common/acting-user'; import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; import * as QRCode from 'qrcode'; @@ -289,12 +291,16 @@ export class TicketsService { reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); } - await this.auditService.log({ - action: 'UPDATE', - entityType: 'Booking', - entityId: bookingId, - newData: { smartReassigned: true, changes: reassigned }, - }); + // Only when a seat actually moved: this runs on every reassignment sweep, including from + // generateMissing()'s batch loop, and an empty `changes` row says nothing happened. + if (reassigned.length > 0) { + await this.auditService.log({ + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Booking, + entityId: bookingId, + newData: { smartReassigned: true, changes: reassigned }, + }); + } return this.generate(bookingId); } @@ -506,7 +512,15 @@ export class TicketsService { }).catch(() => null); } - await this.auditService.log({ action: 'CREATE', entityType: 'Ticket', entityId: booking.id, newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length } }); + // NOTE: entityId is the booking's id, not a ticket id — pre-existing and left as-is so + // historical rows stay queryable the same way. Ticket generation is outside this change's + // scope; see the audit-trail report. + await this.auditService.log({ + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.Ticket, + entityId: booking.id, + newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length }, + }); return { tickets, totalTickets: tickets.length }; } @@ -587,7 +601,12 @@ export class TicketsService { }; } - async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) { + async scanAndBoard( + qrCodeOrRef: string, + validatorId: string, + gateId?: string, + actor?: ActingUser | null, + ) { try { // Extract booking reference from QR code if it's JSON let bookingRef = qrCodeOrRef; @@ -662,7 +681,7 @@ export class TicketsService { } // Use existing validation logic to handle round trips properly - const result = await this.validate(bookingRef, validatorId, gateId); + const result = await this.validate(bookingRef, validatorId, gateId, undefined, actor); if ((result as any).alreadyValidated) { return { success: false, @@ -745,7 +764,21 @@ export class TicketsService { } } - async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) { + /** + * Boards a ticket at the gate. + * + * `validatorId` is the gate/agent label the client sends and keeps its existing meaning on + * `Ticket.validatorId` and `GateValidationLog`. `actor` is the authenticated staff member from + * the request — it is what the audit trail attributes the boarding to, so a caller cannot board + * a passenger under someone else's name by editing the request body. + */ + async validate( + ticketIdOrRef: string, + validatorId: string, + gateId?: string, + leg?: string, + actor?: ActingUser | null, + ) { // Accept either a ticket UUID or a bookingRef let bookingRef = ticketIdOrRef; const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef); @@ -754,7 +787,9 @@ export class TicketsService { if (!ticket) throw new NotFoundException('Ticket not found'); bookingRef = ticket.bookingRef; } - const resolvedValidatorId = validatorId || 'BACKOFFICE'; + // Falls back to the authenticated user before the anonymous 'BACKOFFICE' literal, so an + // omitted validatorId still names a real person on the gate log. + const resolvedValidatorId = validatorId || actor?.id || 'BACKOFFICE'; const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); @@ -763,6 +798,14 @@ export class TicketsService { const type = booking.bookingType; const now = new Date(); + // Snapshotted before any branch mutates the rows, so every audit row below can report the + // status the ticket actually moved away from. + const previousTicketStatus = ticket.status; + const previousValidatedAt = ticket.validatedAt?.toISOString() ?? null; + const previousOutboundBoardedAt = + (booking as any).outboundBoardedAt?.toISOString() ?? null; + const previousReturnBoardedAt = (booking as any).returnBoardedAt?.toISOString() ?? null; + const markTicketUsed = async () => { if (ticket.status !== 'USED') { await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } }); @@ -782,7 +825,24 @@ export class TicketsService { }); await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); this.fireBoardingPassNotification(booking, ticket, null); - await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.BOARD, + entityType: AUDIT_ENTITIES.Ticket, + entityId: ticket.id, + oldData: { status: ticket.status, validatedAt: null, boardedAt: null }, + newData: { + status: 'USED', + validatedAt: now.toISOString(), + boardedAt: now.toISOString(), + leg: 'ONE_WAY', + bookingRef, + bookingId: booking.id, + seatId: ticket.seatId, + ticketLeg: ticket.leg, + gateId, + validatorId: resolvedValidatorId, + }, + }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -797,6 +857,23 @@ export class TicketsService { if (alreadyValidated) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); await markTicketUsed(); + // A refused boarding is exactly the attempt an investigator wants attributed; the gate + // log records it but carries no IAM actor, IP, or user-agent. + await this.auditService.log({ + action: AUDIT_ACTIONS.BOARD_DENIED, + entityType: AUDIT_ENTITIES.Ticket, + entityId: ticket.id, + oldData: { status: ticket.status, validatedAt: ticket.validatedAt?.toISOString() ?? null }, + newData: { + result: 'REJECTED', + reason: `${resolvedLeg}_ALREADY_USED`, + leg: resolvedLeg, + bookingRef, + bookingId: booking.id, + gateId, + validatorId: resolvedValidatorId, + }, + }); throw new BadRequestException(`${resolvedLeg} already validated`); } const validatedAt = ticket.validatedAt ?? now; @@ -810,7 +887,24 @@ export class TicketsService { } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); - await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.BOARD, + entityType: AUDIT_ENTITIES.Ticket, + entityId: ticket.id, + oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt }, + newData: { + status: 'USED', + validatedAt: validatedAt.toISOString(), + boardedAt: now.toISOString(), + leg: resolvedLeg, + bookingRef, + bookingId: booking.id, + seatId: ticket.seatId, + ticketLeg: ticket.leg, + gateId, + validatorId: resolvedValidatorId, + }, + }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } @@ -826,6 +920,21 @@ export class TicketsService { if ((booking as any).outboundBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); await markTicketUsed(); + await this.auditService.log({ + action: AUDIT_ACTIONS.BOARD_DENIED, + entityType: AUDIT_ENTITIES.Ticket, + entityId: ticket.id, + oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt }, + newData: { + result: 'REJECTED', + reason: 'OUTBOUND_ALREADY_USED', + leg: resolvedLeg, + bookingRef, + bookingId: booking.id, + gateId, + validatorId: resolvedValidatorId, + }, + }); throw new BadRequestException('Outbound leg already validated'); } bookingData.outboundBoardedAt = now; @@ -833,6 +942,21 @@ export class TicketsService { if ((booking as any).returnBoardedAt) { await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); await markTicketUsed(); + await this.auditService.log({ + action: AUDIT_ACTIONS.BOARD_DENIED, + entityType: AUDIT_ENTITIES.Ticket, + entityId: ticket.id, + oldData: { status: previousTicketStatus, validatedAt: previousValidatedAt }, + newData: { + result: 'REJECTED', + reason: 'RETURN_ALREADY_USED', + leg: resolvedLeg, + bookingRef, + bookingId: booking.id, + gateId, + validatorId: resolvedValidatorId, + }, + }); throw new BadRequestException('Return leg already validated'); } bookingData.returnBoardedAt = now; @@ -852,7 +976,29 @@ export class TicketsService { } await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); this.fireBoardingPassNotification(booking, ticket, resolvedLeg); - await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } }); + await this.auditService.log({ + action: AUDIT_ACTIONS.BOARD, + entityType: AUDIT_ENTITIES.Ticket, + entityId: ticket.id, + oldData: { + status: previousTicketStatus, + validatedAt: previousValidatedAt, + outboundBoardedAt: previousOutboundBoardedAt, + returnBoardedAt: previousReturnBoardedAt, + }, + newData: { + status: 'USED', + validatedAt: validatedAt.toISOString(), + boardedAt: now.toISOString(), + leg: resolvedLeg, + bookingRef, + bookingId: booking.id, + seatId: ticket.seatId, + ticketLeg: ticket.leg, + gateId, + validatorId: resolvedValidatorId, + }, + }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt }; } @@ -894,7 +1040,7 @@ export class TicketsService { })); } - async validateOfflineBatch(validations: OfflineValidation[]) { + async validateOfflineBatch(validations: OfflineValidation[], actor?: ActingUser | null) { const results = []; for (const validation of validations) { @@ -903,7 +1049,8 @@ export class TicketsService { validation.bookingRef, validation.validatorId, validation.gateId, - validation.leg + validation.leg, + actor, ); results.push({ bookingRef: validation.bookingRef, diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 26108a452..901118389 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -57,6 +57,14 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'eSignet subject identifier for the verified individual (VERIFY flow). A PSUT — ' + + 'pairwise and stable per client_id, never the FIN. The booking flow compares it across ' + + 'passengers so one Fayda identity cannot verify more than one passenger on a booking.', + }) + faydaSub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; 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 df6cfc21d..42376d4a1 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -70,11 +70,19 @@ export interface FaydaUserSummary { /** * Result of completing a verification. `verified` is always true on success. * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity - * attributes (name, email, phone, dob, gender) for the caller to consume. + * attributes (name, email, phone, dob, gender, faydaSub) for the caller to consume. */ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; verified: boolean; + /** + * eSignet subject identifier for the verified individual. This is a PSUT — + * pairwise and stable per `client_id`, never the FIN — so it is safe to hand + * to the browser, and it is the same value `/passengers/me` already returns. + * The booking flow uses it to stop one Fayda identity from verifying more + * than one passenger on the same booking. + */ + faydaSub?: string; token?: string; refreshToken?: string; requiresPassword?: boolean; @@ -280,6 +288,7 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + faydaSub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, @@ -357,6 +366,13 @@ export class VerifaydaService { code_challenge_method: 'S256', acr_values: this.faydaConfig.acrValues, claims_locales: this.faydaConfig.claimsLocales, + // Force a fresh authentication instead of silently reusing the eSignet + // SSO session. A booking can carry several passengers, each of whom must + // verify with their OWN Fayda; without this, the second and third + // "Verify with Fayda" clicks round-trip in a couple of seconds and hand + // back the first passenger's identity, which the booking flow then has to + // reject with no way for the user to authenticate as the right person. + prompt: 'login', }); // Every claim is marked essential so eSignet shows them locked/pre-checked diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts index dace45843..91b0a4f2f 100644 --- a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -130,11 +130,12 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), - asyncStub(), + asyncStub(), // auditService + asyncStub(), // currencyService + asyncStub(), // paymentClient + asyncStub(), // notifications + asyncStub(), // smsClient + asyncStub(), // emailClient ); const charge: any = await service.logCharge({ @@ -174,6 +175,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => { const service = new ExcessBaggageService( prisma as any, asyncStub(), // auditService + asyncStub(), // currencyService asyncStub(), // paymentClient asyncStub(), // notifications asyncStub(), // smsClient diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index fa4b58966..0cc0bbe5e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -1,58 +1,110 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Eye, Download } from 'lucide-react'; +import { Eye, Download, ChevronLeft, ChevronRight } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { auditApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import type { AuditLog } from '@/types/edr'; -export default function AuditLogsPage() { - const [filters, setFilters] = useState({ search: '', action: '', entityType: '' }); +const PAGE_SIZE = 50; + +/** Fallbacks used until GET /audit/vocabulary answers, and if it ever fails. */ +const FALLBACK_ACTIONS = ['CREATE', 'UPDATE', 'DELETE', 'STATUS_CHANGE', 'CANCEL', 'BOARD']; +const FALLBACK_ENTITY_TYPES = ['Station', 'Train', 'Coach', 'Seat', 'Route', 'Schedule', 'Ticket']; + +/** Actions that read as destructive/irreversible, for colouring only. */ +const DESTRUCTIVE_ACTIONS = new Set(['DELETE', 'CANCEL', 'BOARD_DENIED']); +const CREATIVE_ACTIONS = new Set(['CREATE', 'BULK_CREATE', 'RESTORE', 'BOARD', 'PAY']); + +function actionVariant(action: string) { + if (CREATIVE_ACTIONS.has(action)) return 'success'; + if (DESTRUCTIVE_ACTIONS.has(action)) return 'danger'; + if (action === 'LOGIN' || action === 'LOGOUT') return 'info'; + return 'primary'; +} + +function AuditLogsPageContent() { + const [filters, setFilters] = useState({ + search: '', + action: '', + entityType: '', + from: '', + to: '', + }); + const [page, setPage] = useState(0); const [selectedLog, setSelectedLog] = useState(null); const [showDetailsModal, setShowDetailsModal] = useState(false); + const [exporting, setExporting] = useState(false); + + // Any filter change invalidates the current offset. + useEffect(() => setPage(0), [filters]); + + const query = { ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }; const { data, isLoading } = useQuery({ - queryKey: ['audit-logs', filters], - queryFn: () => auditApi.getLogs(filters), - refetchInterval: 30000, // Refetch every 30 seconds + queryKey: ['audit-logs', query], + queryFn: () => auditApi.getLogs(query), + refetchInterval: 30000, }); - const getActionBadgeColor = (action: string) => { - switch (action) { - case 'CREATE': - return 'success'; - case 'UPDATE': - return 'primary'; - case 'DELETE': - return 'danger'; - case 'LOGIN': - return 'info'; - case 'LOGOUT': - return 'secondary'; - default: - return 'secondary'; + const { data: vocabulary } = useQuery({ + queryKey: ['audit-vocabulary'], + queryFn: () => auditApi.getVocabulary(), + staleTime: Infinity, + }); + + // Per-action tallies come from the API's own `total` under the same filters, not from the + // rows on screen — counting the current page made "Total Logs" cap out at the page size. + // limit: 1 keeps these to a COUNT plus a single row. + const creates = useQuery({ + queryKey: ['audit-count', 'CREATE', filters], + queryFn: () => auditApi.getLogs({ ...filters, action: 'CREATE', limit: 1, offset: 0 }), + refetchInterval: 60000, + }); + const updates = useQuery({ + queryKey: ['audit-count', 'UPDATE', filters], + queryFn: () => auditApi.getLogs({ ...filters, action: 'UPDATE', limit: 1, offset: 0 }), + refetchInterval: 60000, + }); + const deletes = useQuery({ + queryKey: ['audit-count', 'DELETE', filters], + queryFn: () => auditApi.getLogs({ ...filters, action: 'DELETE', limit: 1, offset: 0 }), + refetchInterval: 60000, + }); + + const logs: AuditLog[] = Array.isArray(data?.items) ? data.items : []; + const total: number = typeof data?.total === 'number' ? data.total : logs.length; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const actionOptions: string[] = vocabulary?.actions ?? FALLBACK_ACTIONS; + const entityTypeOptions: string[] = vocabulary?.entityTypes ?? FALLBACK_ENTITY_TYPES; + + const formatJsonData = (value: any) => { + if (!value) return 'N/A'; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); } }; - const formatJsonData = (data: any) => { - if (!data) return 'N/A'; - try { - return JSON.stringify(data, null, 2); - } catch { - return String(data); - } - }; + /** Who acted, falling back through the identity the API actually returns. */ + const actorName = (log: AuditLog) => log.userName || 'System'; + const actorDetail = (log: AuditLog) => log.userPhone || log.iamUserId || 'N/A'; const columns = [ { key: 'createdAt', label: 'Timestamp', sortable: true, - render: (log: any) => ( + render: (log: AuditLog) => (
{formatDateTime(log.createdAt)}
{new Date(log.createdAt).toLocaleTimeString()}
@@ -63,17 +115,13 @@ export default function AuditLogsPage() { key: 'action', label: 'Action', sortable: true, - render: (log: any) => ( - - {log.action} - - ), + render: (log: AuditLog) => {log.action}, }, { key: 'entityType', label: 'Entity Type', sortable: true, - render: (log: any) => ( + render: (log: AuditLog) => ( {log.entityType} @@ -82,29 +130,27 @@ export default function AuditLogsPage() { { key: 'entityId', label: 'Entity ID', - render: (log: any) => ( + render: (log: AuditLog) => ( - {log.entityId ? log.entityId.substring(0, 12) : 'System'} + {log.entityId ? log.entityId.substring(0, 12) : '—'} ), }, { key: 'user', label: 'User', - render: (log: any) => ( + render: (log: AuditLog) => (
-
{log.user?.fullName || 'System'}
-
{log.user?.email || log.userId || 'N/A'}
+
{actorName(log)}
+
{actorDetail(log)}
), }, { key: 'ipAddress', label: 'IP Address', - render: (log: any) => ( - - {log.ipAddress || 'N/A'} - + render: (log: AuditLog) => ( + {log.ipAddress || 'N/A'} ), }, ]; @@ -112,7 +158,7 @@ export default function AuditLogsPage() { const actions = [ { label: 'View Details', - onClick: (log: any) => { + onClick: (log: AuditLog) => { setSelectedLog(log); setShowDetailsModal(true); }, @@ -121,12 +167,60 @@ export default function AuditLogsPage() { }, ]; - const logs: any[] = Array.isArray(data?.items) ? data.items : []; - const stats = { - total: logs.length, - creates: logs.filter((l: any) => l.action === 'CREATE').length, - updates: logs.filter((l: any) => l.action === 'UPDATE').length, - deletes: logs.filter((l: any) => l.action === 'DELETE').length, + /** + * Exports every row matching the current filters, not just the page on screen — pulled in + * API-sized batches so a year of logs doesn't arrive as one request. + */ + const exportCsv = async () => { + setExporting(true); + try { + const batch = 200; + const rows: AuditLog[] = []; + for (let offset = 0; offset < total; offset += batch) { + const chunk = await auditApi.getLogs({ ...filters, limit: batch, offset }); + const items: AuditLog[] = Array.isArray(chunk?.items) ? chunk.items : []; + if (!items.length) break; + rows.push(...items); + } + if (!rows.length) return; + + const headers = [ + 'Timestamp', + 'Action', + 'Entity Type', + 'Entity ID', + 'User Name', + 'User Phone', + 'User ID', + 'IP Address', + 'Old Data', + 'New Data', + ]; + const body = rows.map((l) => [ + formatDateTime(l.createdAt), + l.action, + l.entityType, + l.entityId || '', + l.userName || '', + l.userPhone || '', + l.iamUserId || '', + l.ipAddress || '', + l.oldData ? JSON.stringify(l.oldData) : '', + l.newData ? JSON.stringify(l.newData) : '', + ]); + const csv = [headers, ...body] + .map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } }; return ( @@ -136,63 +230,39 @@ export default function AuditLogsPage() {

Audit Logs

Track all system activities and changes

- { - const items: any[] = Array.isArray(data?.items) ? data!.items : []; - if (!items.length) return; - const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address']; - const rows = items.map((l: any) => [ - formatDateTime(l.createdAt), - l.action, - l.entityType, - l.entityId || '', - l.iamUserId || l.userId || '', - l.ipAddress || '', - ]); - const csv = [headers, ...rows].map(r => r.map((v: string) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; - a.click(); - URL.revokeObjectURL(url); - }} - > - Export CSV + + {exporting ? 'Exporting…' : 'Export CSV'} - {/* Stats Cards */} + {/* Stats — counts come from the API under the active filters, not the visible page */}
Total Logs
-
{stats.total}
+
{total}
Created
-
{stats.creates}
+
{creates.data?.total ?? '—'}
Updated
-
{stats.updates}
+
{updates.data?.total ?? '—'}
Deleted
-
{stats.deletes}
+
{deletes.data?.total ?? '—'}
{/* Filters */}
-
-
- +
+
+ setFilters({ ...filters, search: e.target.value })} @@ -206,11 +276,11 @@ export default function AuditLogsPage() { onChange={(e) => setFilters({ ...filters, action: e.target.value })} > - - - - - + {actionOptions.map((a) => ( + + ))}
@@ -221,55 +291,42 @@ export default function AuditLogsPage() { onChange={(e) => setFilters({ ...filters, entityType: e.target.value })} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {entityTypeOptions.map((t) => ( + + ))}
-
- setFilters({ search: '', action: '', entityType: '' })} - className="w-full" - > - Clear Filters - +
+ + setFilters({ ...filters, from: e.target.value })} + />
+
+ + setFilters({ ...filters, to: e.target.value })} + /> +
+
+
+ setFilters({ search: '', action: '', entityType: '', from: '', to: '' })} + > + Clear Filters +
- {/* Data Table */} + {/* Pagination */} +
+

+ {total === 0 + ? 'No results' + : `Showing ${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`} +

+
+ setPage((p) => Math.max(0, p - 1))} + > + Previous + + + Page {page + 1} of {pageCount} + + = pageCount} + onClick={() => setPage((p) => p + 1)} + > + Next + +
+
+ {/* Details Modal */} { setShowDetailsModal(false); setSelectedLog(null); }} + onClose={() => { + setShowDetailsModal(false); + setSelectedLog(null); + }} title="Audit Log Details" size="xl" > - {selectedLog && (() => { - const l = selectedLog; - const actionColor: Record = { - CREATE: 'from-emerald-600 to-emerald-700', - UPDATE: 'from-blue-600 to-blue-700', - DELETE: 'from-red-600 to-red-700', - LOGIN: 'from-violet-600 to-violet-700', - LOGOUT: 'from-gray-600 to-gray-700', - }; - const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700'; + {selectedLog && + (() => { + const l: AuditLog = selectedLog; + const gradient = CREATIVE_ACTIONS.has(l.action) + ? 'from-emerald-600 to-emerald-700' + : DESTRUCTIVE_ACTIONS.has(l.action) + ? 'from-red-600 to-red-700' + : 'from-blue-600 to-blue-700'; - const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( -
-

{label}

-

{value || '—'}

-
- ); - - const SectionHeader = ({ title }: { title: string }) => ( -

- {title} -

- ); - - return ( -
-
-
-
-

Action

-

{l.action}

-
-
- {l.entityType} -

{formatDateTime(l.createdAt)}

-
-
-
-
-

User

-

{l.user?.fullName || 'System'}

-
-
-

IP Address

-

{l.ipAddress || 'N/A'}

-
-
+ const Field = ({ + label, + value, + mono = false, + truncate = false, + }: { + label: string; + value?: string; + mono?: boolean; + truncate?: boolean; + }) => ( +
+

{label}

+

+ {value || '—'} +

+ ); -
-
- -
- - - - + const SectionHeader = ({ title }: { title: string }) => ( +

+ + {title} +

+ ); + + return ( +
+
+
+
+

Action

+

{l.action}

+
+
+ + {l.entityType} + +

{formatDateTime(l.createdAt)}

+
-
+
+
+

User

+

{actorName(l)}

+
+
+

IP Address

+

{l.ipAddress || 'N/A'}

+
+
+
+ +
+
+ +
+ + + + +
+
- {l.user && (
- - - + + +
- )} - {(l.ipAddress || l.userAgent) && ( -
- -
- -
-

User Agent

-

{l.userAgent || '—'}

+ {(l.ipAddress || l.userAgent) && ( +
+ +
+ +
+

User Agent

+

+ {l.userAgent || '—'} +

+
-
-
- )} + + )} + + {(l.oldData || l.newData) && ( +
+ +
+ {l.oldData && ( +
+

+ ← Before +

+
+                              {formatJsonData(l.oldData)}
+                            
+
+ )} + {l.newData && ( +
+

+ → After +

+
+                              {formatJsonData(l.newData)}
+                            
+
+ )} +
+
+ )} - {(l.oldData || l.newData) && (
- -
- {l.oldData && ( -
-

← Before

-
-                            {formatJsonData(l.oldData)}
-                          
-
- )} - {l.newData && ( -
-

→ After

-
-                            {formatJsonData(l.newData)}
-                          
-
- )} + +
+
- )} +
-
- -
- -
-
+
+ { + setShowDetailsModal(false); + setSelectedLog(null); + }} + > + Close + +
- -
- { setShowDetailsModal(false); setSelectedLog(null); }}>Close -
-
- ); - })()} + ); + })()}
); } + +export default function AuditLogsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index a1909d6e8..fffe3722c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -31,6 +31,9 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); + // Mirrors the API guard on POST /payments/:bookingId/force-confirm — + // tickets:generate, with the usual super-admin / org-admin bypass. + const canGenerateTicket = usePermission(PERMS.tickets.generate); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -298,7 +301,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, + { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index af2cab29b..ed6f71acb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -10,8 +10,10 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { seatClassesApi, apiClient } from '@/lib/api'; import { formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function ClassesPage() { +function ClassesPageContent() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingClass, setEditingClass] = useState(null); @@ -352,3 +354,11 @@ export default function ClassesPage() {
); } + +export default function ClassesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index acb0deefa..408ebb352 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -10,6 +10,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; type Tab = 'types' | 'coaches' | 'utilization'; @@ -142,7 +144,7 @@ const renderBedVisualization = (coach: any) => { ); }; -export default function CoachesPage() { +function CoachesPageContent() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); @@ -929,3 +931,11 @@ export default function CoachesPage() {
); } + +export default function CoachesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 7eede0089..cd1bbe131 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -12,6 +12,7 @@ import { ScanLine, } from "lucide-react"; import { dashboardApi } from "@/lib/api/dashboard"; +import DashboardBookingCharts from "@/components/dashboard/DashboardBookingCharts"; import { apiClient } from "@/lib/api-client"; import { formatCurrency } from "@/lib/utils"; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts"; @@ -322,6 +323,9 @@ function DashboardPageContent() { + {/* Booking charts — self-contained; degrades to a single line if its endpoint fails. */} + + {/* Revenue breakdown */}

diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 4f0098792..899e8cd11 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -20,13 +20,15 @@ const features = [ ]; export default function LoginPage() { - const [email, setEmail] = useState(''); + // Accepts an email address, phone number, or username — sent to the IAM in + // the `email` field either way (the backend contract does not change). + const [identifier, setIdentifier] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [showPassword, setShowPassword] = useState(false); const [isMounted, setIsMounted] = useState(false); - const [emailFocused, setEmailFocused] = useState(false); + const [identifierFocused, setIdentifierFocused] = useState(false); const [passwordFocused, setPasswordFocused] = useState(false); const [view, setView] = useState<'login' | 'forgot'>('login'); @@ -47,7 +49,7 @@ export default function LoginPage() { setLoading(true); setError(''); try { - await login(email, password); + await login(identifier.trim(), password); router.push('/dashboard'); } catch (err: any) { const msg = err.message || err.response?.data?.message || ''; @@ -152,26 +154,29 @@ export default function LoginPage() {
- {/* Email field */} + {/* Identifier field — email, phone number, or username */}
{ setEmail(e.target.value); setError(''); }} - onFocus={() => setEmailFocused(true)} - onBlur={() => setEmailFocused(false)} + type="text" + value={identifier} + onChange={(e) => { setIdentifier(e.target.value); setError(''); }} + onFocus={() => setIdentifierFocused(true)} + onBlur={() => setIdentifierFocused(false)} className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" - placeholder="name@edr.com" + placeholder="name@edr.com, +251… or username" required - autoComplete="email" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoComplete="username" />
@@ -209,7 +214,7 @@ export default function LoginPage() {
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index 2f5611f80..ef36ef741 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -2,8 +2,9 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { CheckCircle, Clock, AlertCircle, Ban, Armchair, Download } from "lucide-react"; +import { CheckCircle, Clock, AlertCircle, Ban, Download } from "lucide-react"; import { apiClient } from "@/lib/api-client"; +import FleetSeatOverview from "@/components/reports/FleetSeatOverview"; import Badge from "@/components/ui/Badge"; import ActionButton from "@/components/ui/ActionButton"; import { formatDateTime, formatCurrency } from "@/lib/utils"; @@ -165,12 +166,9 @@ export default function SeatStatusReportPage() { {isError &&

Failed to load report.

}

- {!scheduleId && ( -
- -

Select a schedule above to load the seat status report

-
- )} + {/* Landing state only. Unmounts the moment a schedule is selected, leaving the + per-schedule report below untouched. */} + {!scheduleId && } {data && ( <> diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 251d034b1..a44d78546 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -11,6 +11,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; interface RouteStop { stationId: string; @@ -166,7 +168,7 @@ function RouteCoachesTab({ routes }: { routes: any[] }) { ); } -export default function RoutesPage() { +function RoutesPageContent() { const [activeTab, setActiveTab] = useState('routes'); const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); @@ -922,3 +924,11 @@ export default function RoutesPage() { ); } + +export default function RoutesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 35d0290ea..7738f4b71 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -14,6 +14,8 @@ import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; interface Schedule { id: string; @@ -52,7 +54,7 @@ interface Coach { coachType?: { name: string }; } -export default function SchedulesPage() { +function SchedulesPageContent() { const [showModal, setShowModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); @@ -1248,3 +1250,11 @@ export default function SchedulesPage() { ); } + +export default function SchedulesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index c2d3ef2db..86d61eff0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -6,6 +6,7 @@ import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } import { routesApi } from '@/lib/api/routes'; import { usePermissionStrict } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; @@ -15,7 +16,7 @@ import { SeatBlockReasonCategory, } from '@edr/types'; -export default function SeatsPage() { +function SeatsPageContent() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedRoute, setSelectedRoute] = useState(''); @@ -1491,3 +1492,11 @@ function SeatIcon({ ); } + +export default function SeatsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 032a978a4..420bc3434 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -11,8 +11,10 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { stationsApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function StationsPage() { +function StationsPageContent() { const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [showModal, setShowModal] = useState(false); const [editingStation, setEditingStation] = useState(null); @@ -379,3 +381,11 @@ export default function StationsPage() { ); } + +export default function StationsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index 527aebdc7..e1be87ff3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -13,8 +13,10 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { Train as TrainType } from '@/types'; import { formatDate } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function TrainsPage() { +function TrainsPageContent() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); const [search, setSearch] = useState(''); @@ -366,3 +368,11 @@ export default function TrainsPage() { ); } + +export default function TrainsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/dashboard/DashboardBookingCharts.tsx b/apps/edr-passenger-web/backoffice/src/components/dashboard/DashboardBookingCharts.tsx new file mode 100644 index 000000000..d9c1803f6 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/dashboard/DashboardBookingCharts.tsx @@ -0,0 +1,403 @@ +"use client"; + +/** + * Booking charts for the backoffice dashboard — revenue trend, daily confirmed + * bookings, status distribution and payment-method split over the last 30 days. + * + * Ported from `/reports/overall`, which computes the same four panels in the browser + * from a 5000-row booking fetch. Here the grouping is done by + * `GET /dashboard/analytics/bookings` so the landing page stays light. + * + * Revenue on this panel answers "what was booked" — CONFIRMED and BOARDED bookings by + * creation date. The Revenue Breakdown card below answers "what was collected" (it also + * requires a SUCCEEDED payment intent). The two will not match, which is why each says + * what it measures in its own heading. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + LabelList, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { apiClient } from "@/lib/api-client"; +import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; +import { useTheme } from "@/lib/theme-store"; +import { formatCurrency } from "@/lib/utils"; + +interface BookingAnalytics { + window: { from: string; to: string; days: number }; + totals: { bookings: number; confirmedBookings: number }; + byDay: { + date: string; + bookings: number; + revenueByCurrency: { currency: string; totalMinor: number }[]; + }[]; + statusDistribution: { status: string; count: number }[]; + paymentMethods: { method: string; count: number }[]; +} + +/** + * Fixed colour domain for booking status. Keyed by position in the enum rather than by + * rank in the data, so a day with no cancellations does not repaint the other slices. + */ +const STATUS_ORDER = [ + "CONFIRMED", + "BOARDED", + "PENDING_PAYMENT", + "CANCELLED", + "REFUNDED", + "NO_SHOW", +] as const; + +const STATUS_LABELS: Record = { + CONFIRMED: "Confirmed", + BOARDED: "Boarded", + PENDING_PAYMENT: "Pending payment", + CANCELLED: "Cancelled", + REFUNDED: "Refunded", + NO_SHOW: "No show", + DRAFT: "Draft", + UNKNOWN: "Unknown", +}; + +const MAX_METHOD_BARS = 6; + +/** `YYYY-MM-DD` → `5 Mar`, parsed by parts so no timezone can shift the label. */ +function formatDayLabel(date: string): string { + const [, month, day] = date.split("-"); + const monthName = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][Number(month) - 1]; + return `${Number(day)} ${monthName}`; +} + +function prettyMethod(method: string): string { + return method + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export default function DashboardBookingCharts() { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + // Same query key the dashboard page already uses, so this shares its cache rather + // than issuing a second request for the rates. + const { data: exchangeRates = [] } = useQuery({ + queryKey: ["currencies"], + queryFn: () => apiClient.get("/currencies"), + select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])), + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["dashboard-booking-analytics"], + queryFn: () => apiClient.get("/dashboard/analytics/bookings"), + staleTime: 60_000, + }); + + // Matches the conversion the dashboard page applies to its revenue cards. + const toEtbRate = (currency: string): number | null => { + if (currency === "ETB") return 1; + const r = exchangeRates.find( + (x: any) => x.fromCurrency === "ETB" && x.toCurrency === currency, + ); + return r ? 1 / r.rate : null; + }; + + const dayRows = useMemo( + () => + (data?.byDay ?? []).map((d) => ({ + label: formatDayLabel(d.date), + bookings: d.bookings, + // A currency with no rate on file is left out rather than counted at 1:1. + revenueMinor: d.revenueByCurrency.reduce((sum, r) => { + const rate = toEtbRate(r.currency); + return rate !== null ? sum + Math.round(r.totalMinor * rate) : sum; + }, 0), + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, exchangeRates], + ); + + const statusRows = useMemo( + () => + (data?.statusDistribution ?? []) + .filter((s) => s.count > 0) + .map((s) => ({ + name: STATUS_LABELS[s.status] ?? s.status, + value: s.count, + color: categoricalColor( + palette, + STATUS_ORDER.indexOf(s.status as (typeof STATUS_ORDER)[number]) >= 0 + ? STATUS_ORDER.indexOf(s.status as (typeof STATUS_ORDER)[number]) + : STATUS_ORDER.length, + ), + })), + [data, palette], + ); + + const methodRows = useMemo( + () => + (data?.paymentMethods ?? []).slice(0, MAX_METHOD_BARS).map((m) => ({ + label: prettyMethod(m.method), + count: m.count, + })), + [data], + ); + + const tooltipStyle = { + background: palette.tooltipBg, + border: `1px solid ${palette.tooltipBorder}`, + borderRadius: 8, + fontSize: 12, + }; + + if (isLoading) { + return ( +
+

Loading booking analytics…

+
+ ); + } + + // The dashboard's other cards stand on their own, so a failure here degrades to a + // single quiet line rather than taking the page down. + if (isError || !data) { + return ( +
+

+ Booking analytics are unavailable right now. +

+
+ ); + } + + const hasDays = dayRows.length > 0; + const rangeLabel = `Last ${data.window.days} days`; + + const emptyPanel = ( +
+ No bookings in this range +
+ ); + + return ( +
+ {/* Revenue Trend */} +
+

+ Revenue Trend +

+

+ {rangeLabel} · value of confirmed bookings on the day they were made, in ETB. + Not the same as collected revenue below. +

+ {hasDays ? ( + + + + + Math.round(v / 100).toLocaleString()} + /> + [formatCurrency(value, "ETB"), "Revenue"]} + /> + + + + ) : ( + emptyPanel + )} +
+ + {/* Daily Confirmed Bookings */} +
+

+ Daily Confirmed Bookings +

+

+ {rangeLabel} · how many bookings were confirmed each day. +

+ {hasDays ? ( + + + + + + + + + + ) : ( + emptyPanel + )} +
+ + {/* Booking Status Distribution */} +
+

+ Booking Status Distribution +

+

+ {rangeLabel} · every booking made in the range, by current status. +

+ {statusRows.length > 0 ? ( + <> + {/* Legend with text labels and counts — the palette's light-mode contrast is + validated only with that relief in place. */} +
+ {statusRows.map((s) => ( +
+ + + {s.name} · {s.value} + +
+ ))} +
+ + + + {statusRows.map((s) => ( + + ))} + + + + + + ) : ( + emptyPanel + )} +
+ + {/* Payment Methods */} +
+

+ Payment Methods +

+

+ {rangeLabel} · which method each booking used. “Unknown” means no + payment was started. +

+ {methodRows.length > 0 ? ( + + + + + + + + + + + + ) : ( + emptyPanel + )} +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 616488917..1f33c417e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -81,13 +81,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin }, - { name: 'Trains', href: '/trains', icon: Train }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, - { name: 'Seats', href: '/seats', icon: Armchair }, - { name: 'Classes', href: '/classes', icon: Settings }, - { name: 'Routes', href: '/routes', icon: Route }, - { name: 'Schedules', href: '/schedules', icon: Calendar }, + { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, + { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, + { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, + { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, + { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, + { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/reports/FleetPassengerOverview.tsx b/apps/edr-passenger-web/backoffice/src/components/reports/FleetPassengerOverview.tsx new file mode 100644 index 000000000..c59121005 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reports/FleetPassengerOverview.tsx @@ -0,0 +1,499 @@ +"use client"; + +/** + * Fleet passenger overview — the landing state of the Passengers Report, shown only + * while no schedule is selected. Once a schedule is picked this component unmounts and + * the per-schedule occupancy/list tabs take over unchanged. + * + * Carries no occupancy figure by design. This report and the seat status report measure + * capacity differently (this one counts dining and placeholder seats toward `totalSeats`, + * the other does not), so an occupancy number here would contradict one of them. This + * view answers "who travelled" and leaves "how full" to the seat status report. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Bar, + BarChart, + CartesianGrid, + LabelList, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { CalendarClock, Users } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; +import { useTheme } from "@/lib/theme-store"; + +interface OverviewScheduleRow { + scheduleId: string; + trainNumber: string; + originStation: string; + destinationStation: string; + departureAt: string; + isPackage: boolean; + passengers: number; +} + +interface PassengerOverview { + window: { + from: string; + to: string; + days: number; + direction: "UPCOMING" | "RECENT"; + truncated: boolean; + }; + totals: { + scheduleCount: number; + totalPassengers: number; + groupPassengers: number; + }; + byDay: { date: string; scheduleCount: number; passengers: number }[]; + byNationality: { nationality: string; passengers: number }[]; + byCategory: { category: string; passengers: number }[]; + topRoutes: { origin: string; destination: string; passengers: number }[]; + schedules: OverviewScheduleRow[]; +} + +/** + * Fixed colour domain for passenger category. Keyed by position in this list rather than + * by rank in the data, so a day with no children does not repaint the adult segment. + */ +const CATEGORY_ORDER = ["ADULT", "CHILD"] as const; +const CATEGORY_LABELS: Record = { ADULT: "Adult", CHILD: "Child" }; + +const MAX_NATIONALITY_BARS = 8; + +/** `YYYY-MM-DD` → `05 Mar`, parsed by parts so no timezone can shift the label. */ +function formatDayLabel(date: string): string { + const [, month, day] = date.split("-"); + const monthName = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][Number(month) - 1]; + return `${day} ${monthName}`; +} + +function formatWindow(from: string, to: string): string { + const opts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "short" }; + return `${new Date(from).toLocaleDateString("en-GB", opts)} – ${new Date( + to, + ).toLocaleDateString("en-GB", opts)}`; +} + +export interface FleetPassengerOverviewProps { + /** Selecting a schedule from a chart or row hands control to the drill-down. */ + onSelectSchedule: (scheduleId: string) => void; +} + +export default function FleetPassengerOverview({ + onSelectSchedule, +}: FleetPassengerOverviewProps) { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["passenger-overview"], + queryFn: () => apiClient.get("/reports/passengers/overview"), + }); + + const dayRows = useMemo( + () => (data?.byDay ?? []).map((d) => ({ ...d, label: formatDayLabel(d.date) })), + [data], + ); + + const nationalityRows = useMemo( + () => (data?.byNationality ?? []).slice(0, MAX_NATIONALITY_BARS), + [data], + ); + + const routeRows = useMemo( + () => + (data?.topRoutes ?? []).map((r) => ({ + ...r, + label: `${r.origin} → ${r.destination}`, + })), + [data], + ); + + // One row, one bar, stacked by category — a two-value composition reads better as a + // single bar than as a chart with two lonely columns. + const categoryRow = useMemo(() => { + const row: Record = { name: "mix" }; + for (const c of data?.byCategory ?? []) row[c.category] = c.passengers; + return [row]; + }, [data]); + + const categoriesPresent = useMemo(() => { + const seen = new Set((data?.byCategory ?? []).map((c) => c.category)); + const known = CATEGORY_ORDER.filter((c) => seen.has(c)); + const unknown = [...seen].filter( + (c) => !CATEGORY_ORDER.includes(c as (typeof CATEGORY_ORDER)[number]), + ); + return [...known, ...unknown]; + }, [data]); + + const categoryColor = (category: string) => { + const index = CATEGORY_ORDER.indexOf(category as (typeof CATEGORY_ORDER)[number]); + return categoricalColor(palette, index >= 0 ? index : CATEGORY_ORDER.length); + }; + + const tooltipStyle = { + background: palette.tooltipBg, + border: `1px solid ${palette.tooltipBorder}`, + borderRadius: 8, + fontSize: 12, + }; + + if (isLoading) { + return ( +
+

Loading fleet passenger overview…

+
+ ); + } + + if (isError) { + return ( +
+

Failed to load the fleet passenger overview.

+

+ Select a schedule above to load its report directly. +

+
+ ); + } + + if (!data || data.totals.scheduleCount === 0) { + return ( +
+ +

No departures on record to summarise

+

+ Select a schedule above to load its passengers report +

+
+ ); + } + + const { window: win, totals } = data; + const groupShare = + totals.totalPassengers > 0 + ? Math.round((totals.groupPassengers / totals.totalPassengers) * 100) + : 0; + + return ( +
+ {/* Window banner — the view is fleet-wide until a schedule is chosen, and the + window may be historic, so both facts are stated rather than implied. */} +
+
+
+ +
+

+ All schedules · {formatWindow(win.from, win.to)} +

+

+ {win.direction === "UPCOMING" + ? `Next ${win.days} days — ${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"}` + : `No upcoming departures — showing the most recent ${win.days} days (${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"})`} + {win.truncated && " · truncated to the first 60 departures"} +

+
+
+
+
+ + {/* Window totals */} +
+
+

Passengers

+

+ {totals.totalPassengers} +

+

Confirmed and boarded

+
+
+

Departures

+

+ {totals.scheduleCount} +

+

In this window

+
+
+

Travelling in groups

+

+ {totals.groupPassengers} +

+

+ {groupShare}% of passengers, on multi-seat bookings +

+
+
+ + {/* 1 — Passengers per departure day */} +
+

+ Passengers by departure day +

+

+ How many people travelled each day across every train in the window. +

+ + + + + + + + + +
+ +
+ {/* 2 — Nationality split */} +
+

+ Passengers by nationality +

+

+ Taken from passport country, or Ethiopian where a national ID was used. + “Unknown” means neither was recorded. +

+ + + + + + + + {/* Values printed on the bars — the palette's light-mode contrast is + validated only with numeric relief in place. */} + + + + +
+ + {/* 4 — Busiest origin → destination pairs */} +
+

+ Busiest routes +

+

+ Where people actually travelled from and to — not the train's own + endpoints, but each booking's. +

+ + + + + + + + + + + +
+
+ + {/* 3 — Passenger category mix */} +
+

+ Adult and child mix +

+

+ The whole bar is every passenger in the window, split by fare category. +

+ +
+ {categoriesPresent.map((category) => { + const count = + data.byCategory.find((c) => c.category === category)?.passengers ?? 0; + return ( +
+ + + {CATEGORY_LABELS[category] ?? category} · {count} + +
+ ); + })} +
+ + + + + + + {categoriesPresent.map((category) => ( + + ))} + + +
+ + {/* The same numbers as exact figures, and the picker */} +
+
+

+ Schedules in window +

+

+ The exact numbers behind the charts, one row per departure. Click a row to + open that train's full passengers report. +

+
+
+ + + + {["Departure", "Train", "Route", "Passengers"].map((h) => ( + + ))} + + + + {data.schedules.map((s) => ( + onSelectSchedule(s.scheduleId)} + className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer" + > + + + + + + ))} + +
+ {h} +
+ {new Date(s.departureAt).toLocaleString("en-GB", { + dateStyle: "medium", + timeStyle: "short", + })} + + {s.trainNumber} + {s.isPackage && ( + (package) + )} + + {s.originStation} → {s.destinationStation} + + {s.passengers} +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx b/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx new file mode 100644 index 000000000..2febaa00b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reports/FleetSeatOverview.tsx @@ -0,0 +1,469 @@ +"use client"; + +/** + * Fleet seat overview — the landing state of the Seat Status Report, shown only while + * no schedule is selected. Once a schedule is picked this component unmounts and the + * per-schedule drill-down takes over unchanged. + * + * Its numbers come from `/reports/seat-status/overview`, which applies the same counting + * rules as `/reports/seat-status`, so a schedule's row here equals what the drill-down + * shows after clicking it. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Armchair, CalendarClock, TrendingUp } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; +import { useTheme } from "@/lib/theme-store"; + +interface OverviewScheduleRow { + scheduleId: string; + trainNumber: string; + originStation: string; + destinationStation: string; + departureAt: string; + isPackage: boolean; + sellableSeats: number; + paid: number; + unpaid: number; + expiredHolds: number; + blocked: number; + available: number; + loadFactorPercent: number; +} + +interface OverviewDayBucket { + date: string; + scheduleCount: number; + sellableSeats: number; + paid: number; + unpaid: number; + expiredHolds: number; + blocked: number; + available: number; +} + +interface SeatStatusOverview { + window: { + from: string; + to: string; + days: number; + direction: "UPCOMING" | "RECENT"; + truncated: boolean; + }; + totals: { + scheduleCount: number; + sellableSeats: number; + paidCount: number; + unpaidCount: number; + expiredHoldCount: number; + blockedCount: number; + availableCount: number; + loadFactorPercent: number; + }; + byDay: OverviewDayBucket[]; + schedules: OverviewScheduleRow[]; +} + +/** + * Fixed domain order for the inventory series, matching the left-to-right order of the + * drill-down's summary cards. Colour is keyed by position here and never by rank in the + * data, so a quiet day does not repaint the series. + * + * Expired holds are deliberately absent: a hold that has expired no longer occupies a + * seat, so stacking it against sellable capacity would double-count. It is reported as a + * standalone counter instead. + */ +const INVENTORY_SERIES = [ + { key: "paid", label: "Paid", slot: 0 }, + { key: "unpaid", label: "Unpaid", slot: 1 }, + { key: "blocked", label: "Blocked", slot: 3 }, + { key: "available", label: "Available", slot: -1 }, +] as const; + +const MAX_SCHEDULE_BARS = 12; + +/** `YYYY-MM-DD` → `05 Mar`, parsed by parts so no timezone can shift the label. */ +function formatDayLabel(date: string): string { + const [, month, day] = date.split("-"); + const monthName = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][Number(month) - 1]; + return `${day} ${monthName}`; +} + +function formatWindow(from: string, to: string): string { + const opts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "short" }; + return `${new Date(from).toLocaleDateString("en-GB", opts)} – ${new Date( + to, + ).toLocaleDateString("en-GB", opts)}`; +} + +export interface FleetSeatOverviewProps { + /** Selecting a schedule from a chart hands control to the drill-down. */ + onSelectSchedule: (scheduleId: string) => void; +} + +export default function FleetSeatOverview({ onSelectSchedule }: FleetSeatOverviewProps) { + const isDark = useTheme((s) => s.isDark); + const palette = getChartPalette(isDark); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["seat-status-overview"], + queryFn: () => apiClient.get("/reports/seat-status/overview"), + }); + + const seriesColor = (slot: number) => + slot < 0 ? palette.grid : categoricalColor(palette, slot); + + const dayRows = useMemo( + () => (data?.byDay ?? []).map((d) => ({ ...d, label: formatDayLabel(d.date) })), + [data], + ); + + // Busiest departures first — a 12-bar chart of the whole window would be unreadable, + // and the ones carrying the most seats are the ones worth looking at. + const scheduleRows = useMemo( + () => + (data?.schedules ?? []) + .slice() + .sort((a, b) => b.sellableSeats - a.sellableSeats) + .slice(0, MAX_SCHEDULE_BARS) + .map((s) => ({ + ...s, + label: `${s.trainNumber} · ${new Date(s.departureAt).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + })}`, + })), + [data], + ); + + if (isLoading) { + return ( +
+

Loading fleet seat overview…

+
+ ); + } + + if (isError) { + return ( +
+

Failed to load the fleet seat overview.

+

+ Select a schedule above to load its report directly. +

+
+ ); + } + + if (!data || data.totals.scheduleCount === 0) { + return ( +
+ +

No departures on record to summarise

+

Select a schedule above to load its seat status report

+
+ ); + } + + const { window: win, totals } = data; + + return ( +
+ {/* Window banner — the report is fleet-wide until a schedule is chosen, and the + window may be historic, so both facts are stated rather than implied. */} +
+
+
+ +
+

+ All schedules · {formatWindow(win.from, win.to)} +

+

+ {win.direction === "UPCOMING" + ? `Next ${win.days} days — ${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"}` + : `No upcoming departures — showing the most recent ${win.days} days (${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"})`} + {win.truncated && " · truncated to the first 60 departures"} +

+
+
+
+ + Load factor + + {totals.loadFactorPercent}% + +
+
+
+ + {/* Window totals. Distinct wording from the per-schedule summary cards so the two + are never mistaken for each other. */} +
+ {[ + { label: "Paid", value: totals.paidCount, hint: "Payment confirmed", slot: 0 }, + { label: "Unpaid", value: totals.unpaidCount, hint: "Awaiting payment", slot: 1 }, + { label: "Blocked", value: totals.blockedCount, hint: "Withheld from sale", slot: 3 }, + { label: "Available", value: totals.availableCount, hint: "Still sellable", slot: -1 }, + { + label: "Expired Holds", + value: totals.expiredHoldCount, + hint: "Last 24h, seats released", + slot: -2, + }, + ].map((tile) => ( +
+
+ {tile.slot !== -2 && ( + + )} +
+

{tile.label}

+

+ {tile.value} +

+

{tile.hint}

+
+
+
+ ))} +
+ + {/* Seat mix per departure day */} +
+
+

+ Seat mix by departure day +

+ + {totals.sellableSeats} sellable seats in window + +
+

+ Every seat running on each day, split by what happened to it — paid, waiting on + payment, blocked, or still on sale. The whole bar is that day's capacity. +

+ + {/* Legend carries visible text labels — the palette's light-mode contrast is + validated only with that relief in place. */} +
+ {INVENTORY_SERIES.map((s) => ( +
+ + {s.label} +
+ ))} +
+ + + + + + + + {INVENTORY_SERIES.map((s) => ( + + ))} + + +
+ + {/* Load factor per schedule — doubles as the picker */} +
+

+ Load factor by departure +

+

+ How full each train is — paid seats as a share of the seats it can sell, so 100% + means sold out. Showing the {scheduleRows.length} busiest departure + {scheduleRows.length === 1 ? "" : "s"}; click a bar to open that train's + report. +

+ + + + + + + [ + `${value}% · ${entry?.payload?.paid ?? 0} of ${entry?.payload?.sellableSeats ?? 0} seats`, + "Load factor", + ]} + /> + { + const id = entry?.payload?.scheduleId ?? entry?.scheduleId; + if (id) onSelectSchedule(id); + }} + > + {scheduleRows.map((row) => ( + + ))} + + + +
+ + {/* The same numbers as a table — required relief for the palette's light-mode + contrast, and the only place the per-schedule detail is readable exactly. */} +
+
+

+ Schedules in window +

+

+ The exact numbers behind the charts, one row per departure. Click a row to + open that train's full seat status report. +

+
+
+ + + + {[ + "Departure", + "Train", + "Route", + "Sellable", + "Paid", + "Unpaid", + "Blocked", + "Available", + "Load", + ].map((h) => ( + + ))} + + + + {data.schedules.map((s) => ( + onSelectSchedule(s.scheduleId)} + className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer" + > + + + + + + + + + + + ))} + +
+ {h} +
+ {new Date(s.departureAt).toLocaleString("en-GB", { + dateStyle: "medium", + timeStyle: "short", + })} + + {s.trainNumber} + {s.isPackage && ( + (package) + )} + + {s.originStation} → {s.destinationStation} + + {s.sellableSeats} + {s.paid}{s.unpaid}{s.blocked} + {s.available} + + {s.loadFactorPercent}% +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index f528f9dff..7350928b3 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -380,7 +380,11 @@ export const verifaydaApi = { // Audit API export const auditApi = { getLogs: async (params?: any) => { - const query = new URLSearchParams(params as Record).toString(); + // Drop empty filters so a blank search box doesn't send `search=` and match nothing. + const entries = Object.entries(params ?? {}).filter( + ([, v]) => v !== undefined && v !== null && v !== '', + ); + const query = new URLSearchParams(entries as [string, string][]).toString(); const response = await apiClient.get(`/audit/logs${query ? `?${query}` : ''}`); if (response?.data) { return Array.isArray(response.data) ? { items: response.data } : response; @@ -388,6 +392,7 @@ export const auditApi = { return Array.isArray(response) ? { items: response } : response; }, getLog: (id: string) => apiClient.get(`/audit/logs/${id}`), + getVocabulary: () => apiClient.get('/audit/vocabulary'), }; // Live Tracking API diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts index 05e47039d..aadd86057 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts @@ -18,7 +18,8 @@ interface AuthState { token: string | null; refreshToken: string | null; isAuthenticated: boolean; - login: (email: string, password: string) => Promise; + /** `identifier` may be an email, phone number or username — always sent as `email`. */ + login: (identifier: string, password: string) => Promise; logout: () => void; setUser: (user: AdminUser, token: string) => void; initialize: () => void; @@ -52,9 +53,10 @@ export const useAuthStore = create((set, get) => ({ } }, - login: async (email: string, password: string) => { - // Step 1: IAM login — returns token + refreshToken only - const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password }); + login: async (identifier: string, password: string) => { + // Step 1: IAM login — returns token + refreshToken only. + // The IAM accepts an email, phone number or username in the `email` field. + const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email: identifier, password }); const loginData = loginRes.data?.data ?? loginRes.data; const { token, refreshToken } = loginData; if (!token) throw new Error('No token received from server'); diff --git a/apps/edr-passenger-web/backoffice/src/types/edr.ts b/apps/edr-passenger-web/backoffice/src/types/edr.ts index 1b5ca7a1e..fa864eb71 100644 --- a/apps/edr-passenger-web/backoffice/src/types/edr.ts +++ b/apps/edr-passenger-web/backoffice/src/types/edr.ts @@ -301,7 +301,11 @@ export interface FraudRule { // Audit Types export interface AuditLog { id: string; - userId?: string; + /** IAM id of the staff member who performed the action. The API field is `iamUserId`. */ + iamUserId?: string; + /** Actor's name and phone, denormalized by the API at write time. */ + userName?: string; + userPhone?: string; action: string; entityType: string; entityId?: string; diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 60cd350a2..b48d64d96 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -740,6 +740,10 @@ function PassengersForm() { passportExpiryDate: stored.passportExpiryDate || '', passportIssuingAuthority: stored.passportIssuingAuthority || '', faydaVerified: stored.faydaVerified || false, + // Restore the identity that verified this passenger, so returning here from a + // later step (e.g. Back from /booking/seats) doesn't silently reopen the slot to + // an already-used Fayda. + faydaSub: stored.faydaSub || undefined, formExpanded: true, }; } @@ -852,13 +856,22 @@ function PassengersForm() { if (d?.verified) { const faydaSub: string | undefined = d.sub || d.faydaSub || d.fin; - // A single Fayda identity can't be reused across two different passengers. - const usedByOther = faydaSub && passengers.some( - (p, i) => i !== targetIndex && (p as any).faydaSub === faydaSub, - ); + // A single Fayda identity can't be reused across two different passengers. Read the + // live form rather than the `passengers` captured when this effect was created — the + // snapshot restore repopulates the array as the form initializes. + const currentPassengers = watch('passengers') || []; + const conflictIndex = faydaSub + ? currentPassengers.findIndex( + (p, i) => i !== targetIndex && (p as any)?.faydaSub === faydaSub, + ) + : -1; - if (usedByOther) { - setFaydaErrors((prev) => ({ ...prev, [targetIndex]: 'This Fayda identity is already linked to another passenger on this booking.' })); + if (conflictIndex >= 0) { + const conflictName = currentPassengers[conflictIndex]?.name?.trim(); + setFaydaErrors((prev) => ({ + ...prev, + [targetIndex]: `This Fayda ID has already been used to verify Passenger ${conflictIndex + 1}${conflictName ? ` (${conflictName})` : ''}. Each traveller must verify with their own Fayda.`, + })); setVerificationStatus((prev) => ({ ...prev, [targetIndex]: 'error' })); } else { // Convert "1980/12/01" → "1980-12-01" @@ -963,6 +976,13 @@ function PassengersForm() { const emailVal = pick(passengerData.email, user.email); if (emailVal) setValue('passengers.0.email', emailVal); + // A logged-in, already-verified user occupies slot 0 without going through a fresh + // Fayda round trip, so the callback never records their sub on the form. Seed it from + // the account here, otherwise the duplicate-identity check has nothing to compare + // against and the account holder can re-use their own Fayda on passenger 2. + const accountFaydaSub = pick(passengerData.faydaSub, (user as any).faydaSub); + if (accountFaydaSub) setValue('passengers.0.faydaSub', accountFaydaSub); + if (mustVerifyFayda) { // Force the Fayda gate: leave name/DOB/gender empty and keep the form collapsed so the // "Verify with Fayda" screen is shown instead of an editable, pre-filled form. @@ -1077,6 +1097,13 @@ function PassengersForm() { gender: p.gender, nationality: p.nationality, nationalId: p.nationalId, + // Carry the verified Fayda identity into the booking store so the duplicate-identity + // check still has it if the user comes back to this page from /booking/seats. Without + // it the restore path below rebuilds each passenger without a sub, and one Fayda could + // then re-verify every passenger. Stripped server-side by the global ValidationPipe + // (whitelist: true), so sending it to /passengers/save-details is a no-op there. + faydaVerified: p.faydaVerified, + faydaSub: p.faydaSub, passportNumber: p.passportNumber, passportCountry: p.passportCountry, passportIssueDate: p.passportIssueDate, diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 4b9148001..4d4b954df 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -363,6 +363,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + // Verified Fayda identity for this traveller. The backend stores it on the booking seat and + // refuses a second seat for the same identity on the same departure. + ...(p.faydaSub ? { faydaSub: p.faydaSub } : {}), passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, @@ -417,6 +420,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', idDocumentNumber: isEthiopian ? (p.nationalId || '') : '', + // Verified Fayda identity for this traveller. The backend stores it on the booking seat and + // refuses a second seat for the same identity on the same departure. + ...(p.faydaSub ? { faydaSub: p.faydaSub } : {}), passportNumber: !isEthiopian ? (p.passportNumber || '') : '', passportCountry: !isEthiopian ? (p.passportCountry || '') : '', nationality: p.nationality, diff --git a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx index 209651756..73976fcda 100644 --- a/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/excess-baggage/pay/[token]/page.tsx @@ -1,14 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { PaymentMethod } from "@/types"; import { AlertCircle, + Check, CheckCircle, + Copy, CreditCard, + KeyRound, Landmark, Loader2, Smartphone, @@ -22,6 +25,28 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; +// WALLET is an internal balance debit with no excess-baggage path — the API refuses it, so it is +// never offered here. +const UNSUPPORTED_METHODS = ["WALLET"]; + +// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time +// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could +// collect the number afterwards, so it is asked for up front. +const requiresPayerMobile = (method: string | null) => + method === "CAC_BANK" || method === "EBIRR"; + +// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding, +// so the quote renders exactly the figure the provider will debit. +const formatAmount = (amount: number, currency: string) => + amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2); + +interface AmountQuote { + chargeId: string; + method: string; + currency: string; + amount: number; +} + export default function ExcessBaggagePayPage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); @@ -29,6 +54,26 @@ export default function ExcessBaggagePayPage() { const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC — + // the OTP the bank SMSes to it. + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [payerMobile, setPayerMobile] = useState(""); + const [phoneError, setPhoneError] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(null); + const [pushMessage, setPushMessage] = useState(null); + + // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a + // branch/app later, so the page shows the number and watches for settlement. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ queryKey: ["excessBaggageCharge", token], queryFn: () => apiClient.get(`/excess-baggage/pay/${token}`), @@ -45,24 +90,104 @@ export default function ExcessBaggagePayPage() { enabled: !!charge, }); - const amountDisplay = useMemo(() => { - const amountMinor = Number(charge?.totalMinor ?? charge?.amountMinor ?? 0); - return (amountMinor / 100).toFixed(2); - }, [charge]); + const availableMethods = useMemo( + () => + paymentMethods.filter( + (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type), + ), + [paymentMethods], + ); - const currency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + // The charge is always booked in ETB; this is what it costs before a method is chosen. + const chargeCurrency = charge?.currency ?? charge?.booking?.currency ?? "ETB"; + const chargeAmount = useMemo( + () => Number(charge?.totalMinor ?? charge?.amountMinor ?? 0) / 100, + [charge], + ); + + // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets + // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the + // stored ETB total is not what a Djiboutian wallet would debit. + const { + data: quote, + isFetching: fetchingQuote, + error: quoteError, + } = useQuery({ + queryKey: ["excessBaggageAmount", token, selectedMethod], + queryFn: () => + apiClient.get( + `/excess-baggage/pay/${token}/amount?method=${selectedMethod}`, + ), + enabled: !!token && !!selectedMethod, + retry: false, + staleTime: 30_000, + }); + + // A quote is only usable once it belongs to the method currently selected — otherwise it is a + // leftover from the previous selection and would price the payment in the wrong currency. + const quoteReady = !fetchingQuote && quote?.method === selectedMethod; + + const displayCurrency = selectedMethod + ? (quote?.currency ?? "") + : chargeCurrency; + const displayAmount = selectedMethod ? quote?.amount : chargeAmount; + const amountLabel = + quoteReady && displayAmount != null + ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}` + : !selectedMethod && displayAmount != null + ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}` + : null; + + // Never let Pay fire against a price the payer has not been shown. + const awaitingQuote = !!selectedMethod && !quoteReady; const payMutation = useMutation({ - mutationFn: (method: string) => + mutationFn: (vars: { method: string; payerAccount?: string }) => apiClient.post(`/excess-baggage/pay/${token}/initiate`, { - method, + method: vars.method, platform: "web", + ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}), }), onSuccess: (data: any) => { - if (data?.clientAction?.type === "REDIRECT") { - window.location.href = data.clientAction.url; + const action = data?.clientAction; + + if (action?.type === "REDIRECT") { + window.location.href = action.url; return; } + + // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm. + if (action?.type === "COLLECT_OTP") { + setOtpMessage(action.message ?? "Enter the OTP sent to your phone"); + setOtpCode(""); + setOtpError(null); + setOtpModalOpen(true); + setIsProcessing(false); + return; + } + + // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number. + if (action?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: action.billReference, + instructions: action.instructions, + expiresAt: action.expiresAt, + }); + setBillCopied(false); + setIsProcessing(false); + return; + } + + // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to. + if (action?.type === "AWAIT_PUSH") { + setPushMessage( + action.message ?? + `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`, + ); + setIsProcessing(false); + return; + } + router.push(`/excess-baggage/pay/${token}/result`); }, onError: (err: any) => { @@ -71,11 +196,92 @@ export default function ExcessBaggagePayPage() { }, }); - const handlePay = () => { + // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP — + // keep the modal open so the payer can re-enter it (the intent stays open). + const otpMutation = useMutation({ + mutationFn: (otp: string) => + apiClient.post(`/excess-baggage/pay/${token}/confirm`, { otp }), + onSuccess: () => { + setOtpModalOpen(false); + router.push(`/excess-baggage/pay/${token}/result`); + }, + onError: (err: any) => { + setOtpError( + err?.response?.data?.message ?? + err?.message ?? + "Invalid or expired OTP. Please try again.", + ); + }, + }); + + const startPayment = (mobile?: string) => { if (!selectedMethod) return; setIsProcessing(true); setPaymentError(null); - payMutation.mutate(selectedMethod); + payMutation.mutate({ + method: selectedMethod, + payerAccount: requiresPayerMobile(selectedMethod) + ? mobile?.trim() + : undefined, + }); + }; + + const handlePay = () => { + if (!selectedMethod || awaitingQuote) return; + setPaymentError(null); + + if (requiresPayerMobile(selectedMethod)) { + // Prefill with the number the charge was raised against, but leave it editable — the + // handset paying is often not the one the booking was made under. + if (!payerMobile.trim() && charge?.contactPhone) { + setPayerMobile(charge.contactPhone); + } + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + + // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens + // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal. + // Success is only ever claimed from this, never from a client-side guess. + const watching = !!billAction || !!pushMessage; + const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({ + queryKey: ["excessBaggageStatus", token], + queryFn: () => + apiClient.get<{ status: string; paid: boolean }>( + `/excess-baggage/pay/${token}/status`, + ), + enabled: !!token && watching, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (watching && liveStatus?.paid) { + router.push(`/excess-baggage/pay/${token}/result`); + } + }, [watching, liveStatus?.paid, router, token]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } }; if (loadingCharge) { @@ -112,10 +318,25 @@ export default function ExcessBaggagePayPage() {
Amount due - - {currency} {amountDisplay} - + {amountLabel ? ( + {amountLabel} + ) : quoteError ? ( + + ) : ( + + )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && ( +

+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate +

+ )} + {quoteError && ( +

+ {(quoteError as any)?.response?.data?.message ?? + "This payment method is unavailable right now. Please choose another."} +

+ )}
Weight {charge.excessWeightKg ?? "—"} kg @@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( @@ -166,17 +387,181 @@ export default function ExcessBaggagePayPage() { + + {/* CBE bill — show the number; confirmation only ever comes from the status poll */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount: ETB {formatAmount(chargeAmount, "ETB")} +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {new Date(billAction.expiresAt).toLocaleString()} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + + {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */} + {pushMessage && ( +
+ +
+

Check your phone

+

{pushMessage}

+
+
+ )} + + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ {selectedMethod === "EBIRR" + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."} +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }} + placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"} + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

{otpMessage}

+ { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }} + placeholder="Enter code" + maxLength={10} + className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {otpError && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )}
); diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 7337c18d6..57aad945b 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -62,6 +62,9 @@ export const metadata: Metadata = { shortcut: '/edr-logo.png', apple: '/edr-logo.png', }, + other: { + google: 'notranslate', + }, }; // viewportFit: 'cover' lets fixed bottom bars (e.g. the payment page's Pay @@ -117,7 +120,7 @@ export default function RootLayout({ }; return ( - + diff --git a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx index fb2135cd3..da45893e5 100644 --- a/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/pay-balance/[token]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; @@ -8,7 +8,10 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { PaymentMethod } from "@/types"; import { Loader2, + Check, + Copy, CreditCard, + KeyRound, Smartphone, Wallet, Landmark, @@ -23,6 +26,28 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; +// WALLET is an internal balance debit with no supplementary-charge path — the API refuses it, so +// it is never offered here. +const UNSUPPORTED_METHODS = ["WALLET"]; + +// Push-debit methods charge an account we must know before initiating: CAC Bank SMSes a one-time +// password to it, eBirr pushes a USSD PIN prompt to it. Neither opens a hosted page that could +// collect the number afterwards, so it is asked for up front. +const requiresPayerMobile = (method: string | null) => + method === "CAC_BANK" || method === "EBIRR"; + +// DJF has no minor unit; ETB and USD are quoted to cents. Matches the API's charge-side rounding, +// so the quote renders exactly the figure the provider will debit. +const formatAmount = (amount: number, currency: string) => + amount.toFixed(currency.toUpperCase() === "DJF" ? 0 : 2); + +interface AmountQuote { + chargeId: string; + method: string; + currency: string; + amount: number; +} + export default function PayBalancePage() { const { token } = useParams<{ token: string }>(); const router = useRouter(); @@ -30,6 +55,26 @@ export default function PayBalancePage() { const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); + // Push-debit (CAC Bank / eBirr): collect the payer's mobile before initiating, then — for CAC — + // the OTP the bank SMSes to it. + const [phoneModalOpen, setPhoneModalOpen] = useState(false); + const [payerMobile, setPayerMobile] = useState(""); + const [phoneError, setPhoneError] = useState(null); + const [otpModalOpen, setOtpModalOpen] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [otpMessage, setOtpMessage] = useState(null); + const [otpError, setOtpError] = useState(null); + const [pushMessage, setPushMessage] = useState(null); + + // CBE bill: no redirect and no OTP — the payer walks away with a bill number and pays it at a + // branch/app later, so the page shows the number and watches for settlement. + const [billAction, setBillAction] = useState<{ + billReference: string; + instructions?: string; + expiresAt?: string; + } | null>(null); + const [billCopied, setBillCopied] = useState(false); + const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({ queryKey: ["supplementary-charge", token], queryFn: () => apiClient.get(`/payments/supplementary/by-token/${token}`), @@ -45,18 +90,102 @@ export default function PayBalancePage() { enabled: !!charge, }); + const availableMethods = useMemo( + () => + paymentMethods.filter( + (m) => m.enabled && !UNSUPPORTED_METHODS.includes(m.type), + ), + [paymentMethods], + ); + + // The charge is raised in ETB; this is what it costs before a method is chosen. + const chargeCurrency = charge?.currency ?? "ETB"; + const chargeAmount = useMemo( + () => Number(charge?.amountMinor ?? 0) / 100, + [charge], + ); + + // Each method settles in its own currency (WAAFI/DMONEY in DJF, CARD in USD, Ethiopian wallets + // in ETB), so the price has to be re-quoted server-side whenever the selection changes — the + // stored ETB amount is not what a Djiboutian wallet would debit. + const { + data: quote, + isFetching: fetchingQuote, + error: quoteError, + } = useQuery({ + queryKey: ["supplementaryAmount", token, selectedMethod], + queryFn: () => + apiClient.get( + `/payments/supplementary/by-token/${token}/amount?method=${selectedMethod}`, + ), + enabled: !!token && !!selectedMethod, + retry: false, + staleTime: 30_000, + }); + + // A quote is only usable once it belongs to the method currently selected — otherwise it is a + // leftover from the previous selection and would price the payment in the wrong currency. + const quoteReady = !fetchingQuote && quote?.method === selectedMethod; + const displayCurrency = selectedMethod ? (quote?.currency ?? "") : chargeCurrency; + const displayAmount = selectedMethod ? quote?.amount : chargeAmount; + const amountLabel = + quoteReady && displayAmount != null + ? `${displayCurrency} ${formatAmount(displayAmount, displayCurrency)}` + : !selectedMethod && displayAmount != null + ? `${chargeCurrency} ${formatAmount(displayAmount, chargeCurrency)}` + : null; + + // Never let Pay fire against a price the payer has not been shown. + const awaitingQuote = !!selectedMethod && !quoteReady; + const payMutation = useMutation({ - mutationFn: (method: string) => + mutationFn: (vars: { method: string; payerAccount?: string }) => apiClient.post(`/payments/supplementary/by-token/${token}/pay`, { - method, + method: vars.method, platform: "web", + ...(vars.payerAccount ? { payerAccount: vars.payerAccount } : {}), }), onSuccess: (data: any) => { - if (data?.clientAction?.type === "REDIRECT") { - window.location.href = resolvePaymentRedirectUrl(data.clientAction.url); + const action = data?.clientAction; + + if (action?.type === "REDIRECT") { + window.location.href = resolvePaymentRedirectUrl(action.url); return; } - // Immediate success (e.g. wallet) + + // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it here and confirm. + if (action?.type === "COLLECT_OTP") { + setOtpMessage(action.message ?? "Enter the OTP sent to your phone"); + setOtpCode(""); + setOtpError(null); + setOtpModalOpen(true); + setIsProcessing(false); + return; + } + + // CBE: the bill now exists in CBE's system. Nothing to navigate to — show the number. + if (action?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + billReference: action.billReference, + instructions: action.instructions, + expiresAt: action.expiresAt, + }); + setBillCopied(false); + setIsProcessing(false); + return; + } + + // eBirr: the PIN prompt was pushed to the payer's handset; there is nothing to navigate to. + if (action?.type === "AWAIT_PUSH") { + setPushMessage( + action.message ?? + `Approve the payment on your phone${action.payerAccountMasked ? ` (${action.payerAccountMasked})` : ""}.`, + ); + setIsProcessing(false); + return; + } + + // Immediate success router.push(`/pay-balance/${token}/success`); }, onError: (err: any) => { @@ -67,11 +196,90 @@ export default function PayBalancePage() { }, }); - const handlePay = () => { + // CAC Bank OTP confirmation. A 200 means the debit settled; a 400 is a wrong/expired OTP — + // keep the modal open so the payer can re-enter it (the intent stays open). + const otpMutation = useMutation({ + mutationFn: (otp: string) => + apiClient.post(`/payments/supplementary/by-token/${token}/confirm`, { otp }), + onSuccess: () => { + setOtpModalOpen(false); + router.push(`/pay-balance/${token}/success`); + }, + onError: (err: any) => { + setOtpError( + err?.response?.data?.message ?? + err?.message ?? + "Invalid or expired OTP. Please try again.", + ); + }, + }); + + const startPayment = (mobile?: string) => { if (!selectedMethod) return; setIsProcessing(true); setPaymentError(null); - payMutation.mutate(selectedMethod); + payMutation.mutate({ + method: selectedMethod, + payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined, + }); + }; + + const handlePay = () => { + if (!selectedMethod || awaitingQuote) return; + setPaymentError(null); + + if (requiresPayerMobile(selectedMethod)) { + // Prefill with the number the charge was raised against, but leave it editable — the + // handset paying is often not the one the booking was made under. + if (!payerMobile.trim() && charge?.booking?.contactPhone) { + setPayerMobile(charge.booking.contactPhone); + } + setPhoneError(null); + setPhoneModalOpen(true); + return; + } + + startPayment(); + }; + + const submitPhone = () => { + if (!payerMobile.trim()) { + setPhoneError("Please enter your mobile number"); + return; + } + setPhoneModalOpen(false); + startPayment(payerMobile); + }; + + // While a bill or a pushed PIN prompt is outstanding, watch the charge. Settlement happens + // server-side — a CBE teller, or the provider's webhook — so the browser has no other signal. + // Success is only ever claimed from this, never from a client-side guess. + const watching = !!billAction || !!pushMessage; + const { data: liveStatus } = useQuery<{ status: string; paid: boolean }>({ + queryKey: ["supplementaryStatus", token], + queryFn: () => + apiClient.get<{ status: string; paid: boolean }>( + `/payments/supplementary/by-token/${token}/status`, + ), + enabled: !!token && watching, + refetchInterval: 5_000, + }); + + useEffect(() => { + if (watching && liveStatus?.paid) { + router.push(`/pay-balance/${token}/success`); + } + }, [watching, liveStatus?.paid, router, token]); + + const copyBillReference = async () => { + if (!billAction) return; + try { + await navigator.clipboard.writeText(billAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } catch { + /* clipboard unavailable — the number is still shown on screen */ + } }; if (loadingCharge) { @@ -95,8 +303,6 @@ export default function PayBalancePage() { ); } - const amountDisplay = (charge.amountMinor / 100).toFixed(2); - const currency = charge.currency ?? "ETB"; return (
@@ -122,8 +328,25 @@ export default function PayBalancePage() { )}
Amount due - {currency} {amountDisplay} + {amountLabel ? ( + {amountLabel} + ) : quoteError ? ( + + ) : ( + + )}
+ {selectedMethod && quoteReady && displayCurrency !== chargeCurrency && ( +

+ Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate +

+ )} + {quoteError && ( +

+ {(quoteError as any)?.response?.data?.message ?? + "This payment method is unavailable right now. Please choose another."} +

+ )}
{/* Payment methods */} @@ -136,7 +359,7 @@ export default function PayBalancePage() {
) : (
- {paymentMethods.filter((m) => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( @@ -173,19 +396,180 @@ export default function PayBalancePage() {

🔒 Secure & encrypted payment

+ + {/* CBE bill — show the number; confirmation only ever comes from the status poll */} + {billAction && ( +
+
+
+ +

Pay at CBE

+
+

+ {billAction.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} +

+
+ + {billAction.billReference} + + +
+
+

+ Amount: ETB {formatAmount(chargeAmount, "ETB")} +

+ {billAction.expiresAt && ( +

+ Pay before:{" "} + + {new Date(billAction.expiresAt).toLocaleString()} + +

+ )} +
+
+ + Waiting for payment confirmation — this page updates automatically once CBE + confirms your payment. +
+ +
+
+ )} + + {/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */} + {pushMessage && ( +
+ +
+

Check your phone

+

{pushMessage}

+
+
+ )} + + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} + {phoneModalOpen && ( +
+
+
+ +

Your mobile number

+
+

+ {selectedMethod === "EBIRR" + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."} +

+ { setPayerMobile(e.target.value); setPhoneError(null); }} + onKeyDown={(e) => { if (e.key === "Enter") submitPhone(); }} + placeholder={selectedMethod === "EBIRR" ? "09XX XXX XXX" : "77 XX XX XX"} + className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {phoneError && ( +

⚠️ {phoneError}

+ )} +
+ + +
+
+
+ )} + + {/* CAC Bank OTP entry */} + {otpModalOpen && ( +
+
+
+ +

Enter OTP

+
+

{otpMessage}

+ { setOtpCode(e.target.value.replace(/\D/g, "")); setOtpError(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && otpCode.trim() && !otpMutation.isPending) otpMutation.mutate(otpCode.trim()); }} + placeholder="Enter code" + maxLength={10} + className="w-full text-center tracking-[0.4em] text-lg font-semibold px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" + /> + {otpError && ( +

⚠️ {otpError}

+ )} +
+ + +
+
+
+ )}
);