mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
124
apps/edr-passenger-api/src/common/audit-snapshot.ts
Normal file
124
apps/edr-passenger-api/src/common/audit-snapshot.ts
Normal file
@@ -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<T extends object>(
|
||||
entity: T | null | undefined,
|
||||
keys: readonly (keyof T & string)[],
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!entity) return undefined;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
const value = (entity as Record<string, unknown>)[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<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown> | undefined,
|
||||
after: Record<string, unknown> | undefined,
|
||||
): { oldData?: Record<string, unknown>; newData?: Record<string, unknown> } {
|
||||
if (!before || !after) return { oldData: before, newData: after };
|
||||
|
||||
const oldData: Record<string, unknown> = {};
|
||||
const newData: Record<string, unknown> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
91
apps/edr-passenger-api/src/common/audit.actions.ts
Normal file
91
apps/edr-passenger-api/src/common/audit.actions.ts
Normal file
@@ -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];
|
||||
224
apps/edr-passenger-api/src/common/audit.service.spec.ts
Normal file
224
apps/edr-passenger-api/src/common/audit.service.spec.ts
Normal file
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
115
apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts
Normal file
115
apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts
Normal file
@@ -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<number, string> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null | undefined>,
|
||||
): Promise<void> {
|
||||
const nameByIdentity = new Map<string, string>();
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReturnType<typeof this.calculateFare>>;
|
||||
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, number> }
|
||||
>();
|
||||
const statusCounts = new Map<string, number>();
|
||||
const methodCounts = new Map<string, number>();
|
||||
|
||||
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<string, number>() };
|
||||
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 = <T extends { count: number }>(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([
|
||||
|
||||
@@ -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<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: ExcessBaggageService;
|
||||
|
||||
const build = (charge: Record<string, any> = {}) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string>([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<string>([
|
||||
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<Date> {
|
||||
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<string | null> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<BillQueryResponseDto> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, any> = {}) =>
|
||||
({
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BillQueryResponseDto> {
|
||||
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<BillQueryResponseDto> {
|
||||
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<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
PAYMENT_METHOD_AUDIT_FIELDS.filter((k) => method[k] !== undefined).map((k) => [
|
||||
k,
|
||||
method[k],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
|
||||
@@ -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<MarkPaidResponseDto> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: SupplementaryChargesService;
|
||||
|
||||
const build = (charge: Record<string, any> = {}) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>([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<string>([
|
||||
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<string | null> {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any>;
|
||||
let paymentClient: Record<string, jest.Mock>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, number>();
|
||||
const coachIdsBySchedule = new Map<string, Set<string>>();
|
||||
for (const assignment of assignments) {
|
||||
const coachIds =
|
||||
coachIdsBySchedule.get(assignment.scheduleId) ?? new Set<string>();
|
||||
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<string, number>();
|
||||
const unpaidBySchedule = new Map<string, number>();
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
for (const schedule of schedules) {
|
||||
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
|
||||
const countedSeatIds = new Map<string, string | null>();
|
||||
|
||||
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<string, ReturnType<typeof emptyDayBucket>>();
|
||||
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<string, number>();
|
||||
const nationalityCounts = new Map<string, number>();
|
||||
const categoryCounts = new Map<string, number>();
|
||||
const routeCounts = new Map<string, { origin: string; destination: string; passengers: number }>();
|
||||
// A booking contributing more than one seat to the window is a group booking.
|
||||
const seatsPerBooking = new Map<string, number>();
|
||||
|
||||
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<string, { date: string; scheduleCount: number; passengers: number }>();
|
||||
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 = <T extends { passengers: number }>(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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: SchedulesService;
|
||||
|
||||
const scheduleRow = (over: Record<string, any> = {}) => ({
|
||||
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<string, any> = {}) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<CreateFareRuleDto>) {
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, any>;
|
||||
let audit: { log: jest.Mock };
|
||||
let service: TicketsService;
|
||||
|
||||
const build = (
|
||||
opts: {
|
||||
bookingType?: string;
|
||||
ticket?: Record<string, any>;
|
||||
booking?: Record<string, any>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<any>(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) => (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
|
||||
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
|
||||
@@ -63,17 +115,13 @@ export default function AuditLogsPage() {
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
sortable: true,
|
||||
render: (log: any) => (
|
||||
<Badge className={getActionBadgeColor(log.action)}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
),
|
||||
render: (log: AuditLog) => <Badge className={actionVariant(log.action)}>{log.action}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'entityType',
|
||||
label: 'Entity Type',
|
||||
sortable: true,
|
||||
render: (log: any) => (
|
||||
render: (log: AuditLog) => (
|
||||
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
|
||||
{log.entityType}
|
||||
</span>
|
||||
@@ -82,29 +130,27 @@ export default function AuditLogsPage() {
|
||||
{
|
||||
key: 'entityId',
|
||||
label: 'Entity ID',
|
||||
render: (log: any) => (
|
||||
render: (log: AuditLog) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
|
||||
{log.entityId ? log.entityId.substring(0, 12) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
render: (log: any) => (
|
||||
render: (log: AuditLog) => (
|
||||
<div>
|
||||
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
|
||||
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
|
||||
<div className="font-medium text-sm">{actorName(log)}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{actorDetail(log)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ipAddress',
|
||||
label: 'IP Address',
|
||||
render: (log: any) => (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{log.ipAddress || 'N/A'}
|
||||
</span>
|
||||
render: (log: AuditLog) => (
|
||||
<span className="text-xs text-muted-foreground font-mono">{log.ipAddress || 'N/A'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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() {
|
||||
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
|
||||
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
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
|
||||
<ActionButton icon={Download} variant="secondary" onClick={exportCsv} disabled={exporting || !total}>
|
||||
{exporting ? 'Exporting…' : 'Export CSV'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{/* Stats — counts come from the API under the active filters, not the visible page */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.total}</div>
|
||||
<div className="text-2xl font-bold mt-2">{total}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-green-600 text-sm font-medium">Created</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
|
||||
<div className="text-2xl font-bold mt-2">{creates.data?.total ?? '—'}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-blue-600 text-sm font-medium">Updated</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
|
||||
<div className="text-2xl font-bold mt-2">{updates.data?.total ?? '—'}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-red-600 text-sm font-medium">Deleted</div>
|
||||
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
|
||||
<div className="text-2xl font-bold mt-2">{deletes.data?.total ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="label">Search (User/Entity ID)</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="label">Search (User / Entity ID)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
placeholder="Name, phone, user ID, entity ID…"
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
@@ -206,11 +276,11 @@ export default function AuditLogsPage() {
|
||||
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="CREATE">Create</option>
|
||||
<option value="UPDATE">Update</option>
|
||||
<option value="DELETE">Delete</option>
|
||||
<option value="LOGIN">Login</option>
|
||||
<option value="LOGOUT">Logout</option>
|
||||
{actionOptions.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -221,55 +291,42 @@ export default function AuditLogsPage() {
|
||||
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<optgroup label="Master Data">
|
||||
<option value="Station">Station</option>
|
||||
<option value="Route">Route</option>
|
||||
<option value="RouteStop">Route Stop</option>
|
||||
<option value="Train">Train</option>
|
||||
<option value="TrainSchedule">Train Schedule</option>
|
||||
<option value="Coach">Coach</option>
|
||||
<option value="CoachType">Coach Type</option>
|
||||
<option value="SeatClass">Seat Class</option>
|
||||
<option value="FareRule">Fare Rule</option>
|
||||
<option value="RouteFareRule">Route Fare Rule</option>
|
||||
<option value="SegmentFareRule">Segment Fare Rule</option>
|
||||
<option value="BaggageAllowance">Baggage Allowance</option>
|
||||
</optgroup>
|
||||
<optgroup label="Operations">
|
||||
<option value="Booking">Booking</option>
|
||||
<option value="Payment">Payment</option>
|
||||
<option value="Ticket">Ticket</option>
|
||||
<option value="Seat">Seat</option>
|
||||
<option value="SeatBlock">Seat Block</option>
|
||||
</optgroup>
|
||||
<optgroup label="Users & Access">
|
||||
<option value="User">User</option>
|
||||
<option value="Agent">Agent</option>
|
||||
<option value="Passenger">Passenger</option>
|
||||
</optgroup>
|
||||
<optgroup label="System & Features">
|
||||
<option value="Notification">Notification</option>
|
||||
<option value="Promotion">Promotion</option>
|
||||
<option value="Loyalty">Loyalty</option>
|
||||
<option value="Wallet">Wallet</option>
|
||||
<option value="FraudAlert">Fraud Alert</option>
|
||||
<option value="FraudRule">Fraud Rule</option>
|
||||
</optgroup>
|
||||
{entityTypeOptions.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
</ActionButton>
|
||||
<div>
|
||||
<label className="label">From</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">To</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setFilters({ search: '', action: '', entityType: '', from: '', to: '' })}
|
||||
>
|
||||
Clear Filters
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
data={logs}
|
||||
columns={columns}
|
||||
@@ -278,136 +335,205 @@ export default function AuditLogsPage() {
|
||||
emptyMessage="No audit logs found"
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total === 0
|
||||
? 'No results'
|
||||
: `Showing ${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, total)} of ${total}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
icon={ChevronLeft}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</ActionButton>
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
Page {page + 1} of {pageCount}
|
||||
</span>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
icon={ChevronRight}
|
||||
disabled={page + 1 >= pageCount}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Modal
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
|
||||
onClose={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedLog(null);
|
||||
}}
|
||||
title="Audit Log Details"
|
||||
size="xl"
|
||||
>
|
||||
{selectedLog && (() => {
|
||||
const l = selectedLog;
|
||||
const actionColor: Record<string, string> = {
|
||||
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 }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
|
||||
<p className="text-white text-2xl font-bold">{l.action}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
|
||||
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">User</p>
|
||||
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">IP Address</p>
|
||||
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
const Field = ({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
truncate = false,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
truncate?: boolean;
|
||||
}) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p
|
||||
className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`}
|
||||
title={value}
|
||||
>
|
||||
{value || '—'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Event Details" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Action" value={l.action} />
|
||||
<Field label="Entity Type" value={l.entityType} mono />
|
||||
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
|
||||
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />
|
||||
{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
|
||||
<p className="text-white text-2xl font-bold">{l.action}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">
|
||||
{l.entityType}
|
||||
</span>
|
||||
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">User</p>
|
||||
<p className="text-white text-sm font-bold truncate">{actorName(l)}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">IP Address</p>
|
||||
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Event Details" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Action" value={l.action} />
|
||||
<Field label="Entity Type" value={l.entityType} mono />
|
||||
<Field label="Entity ID" value={l.entityId} mono truncate />
|
||||
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{l.user && (
|
||||
<section>
|
||||
<SectionHeader title="User Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Full Name" value={l.user.fullName} />
|
||||
<Field label="Email" value={l.user.email} truncate />
|
||||
<Field label="User ID" value={l.userId} mono truncate />
|
||||
<Field label="Name" value={l.userName} />
|
||||
<Field label="Phone" value={l.userPhone} mono />
|
||||
<Field label="IAM User ID" value={l.iamUserId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(l.ipAddress || l.userAgent) && (
|
||||
<section>
|
||||
<SectionHeader title="Network Information" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="IP Address" value={l.ipAddress} mono />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
|
||||
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
|
||||
{(l.ipAddress || l.userAgent) && (
|
||||
<section>
|
||||
<SectionHeader title="Network Information" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="IP Address" value={l.ipAddress} mono />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
|
||||
<p className="text-xs font-mono text-foreground break-all leading-relaxed">
|
||||
{l.userAgent || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(l.oldData || l.newData) && (
|
||||
<section>
|
||||
<SectionHeader title="Data Changes" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{l.oldData && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide">
|
||||
← Before
|
||||
</p>
|
||||
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||
{formatJsonData(l.oldData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{l.newData && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide">
|
||||
→ After
|
||||
</p>
|
||||
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||
{formatJsonData(l.newData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(l.oldData || l.newData) && (
|
||||
<section>
|
||||
<SectionHeader title="Data Changes" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{l.oldData && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide">← Before</p>
|
||||
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||
{formatJsonData(l.oldData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{l.newData && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide">→ After</p>
|
||||
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||
{formatJsonData(l.newData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<SectionHeader title="System" />
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Field label="Log ID" value={l.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="System" />
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Field label="Log ID" value={l.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedLog(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.audit.view}>
|
||||
<AuditLogsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<BookingFilters>({ 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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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<any>(null);
|
||||
@@ -352,3 +354,11 @@ export default function ClassesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClassesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.classes.view}>
|
||||
<ClassesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Tab>('coaches');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -929,3 +931,11 @@ export default function CoachesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoachesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.coaches.view}>
|
||||
<CoachesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking charts — self-contained; degrades to a single line if its endpoint fails. */}
|
||||
<DashboardBookingCharts />
|
||||
|
||||
{/* Revenue breakdown */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
||||
|
||||
{/* Email field */}
|
||||
{/* Identifier field — email, phone number, or username */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Email address
|
||||
Email, phone or username
|
||||
</label>
|
||||
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||
emailFocused
|
||||
identifierFocused
|
||||
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
||||
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||
}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +214,7 @@ export default function LoginPage() {
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView('forgot'); setForgotIdentifier(email); setError(''); }}
|
||||
onClick={() => { setView('forgot'); setForgotIdentifier(identifier); setError(''); }}
|
||||
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
@@ -220,9 +225,9 @@ export default function LoginPage() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email || !password}
|
||||
disabled={loading || !identifier.trim() || !password}
|
||||
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: loading || !email || !password
|
||||
style={{ background: loading || !identifier.trim() || !password
|
||||
? 'rgb(20,113,76)'
|
||||
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Users, Armchair, BarChart3, Train, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import FleetPassengerOverview from "@/components/reports/FleetPassengerOverview";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
@@ -585,12 +586,9 @@ export default function PassengersReportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the occupancy report</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Landing state only. Unmounts the moment a schedule is selected, leaving the
|
||||
per-schedule report above untouched. */}
|
||||
{!scheduleId && <FleetPassengerOverview onSelectSchedule={setScheduleId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
|
||||
</div>
|
||||
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the seat status report</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Landing state only. Unmounts the moment a schedule is selected, leaving the
|
||||
per-schedule report below untouched. */}
|
||||
{!scheduleId && <FleetSeatOverview onSelectSchedule={setScheduleId} />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
|
||||
@@ -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<Tab>('routes');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
@@ -922,3 +924,11 @@ export default function RoutesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.routes.view}>
|
||||
<RoutesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulesPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.schedules.view}>
|
||||
<SchedulesPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeatsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.seats.view}>
|
||||
<SeatsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<any>(null);
|
||||
@@ -379,3 +381,11 @@ export default function StationsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StationsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.stations.view}>
|
||||
<StationsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TrainType | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -366,3 +368,11 @@ export default function TrainsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrainsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.trains.view}>
|
||||
<TrainsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<any[]>({
|
||||
queryKey: ["currencies"],
|
||||
queryFn: () => apiClient.get("/currencies"),
|
||||
select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])),
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BookingAnalytics>({
|
||||
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 (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<p className="text-sm">Loading booking analytics…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="card py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Booking analytics are unavailable right now.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDays = dayRows.length > 0;
|
||||
const rangeLabel = `Last ${data.window.days} days`;
|
||||
|
||||
const emptyPanel = (
|
||||
<div className="h-[260px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No bookings in this range
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Revenue Trend
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · value of confirmed bookings on the day they were made, in ETB.
|
||||
Not the same as collected revenue below.
|
||||
</p>
|
||||
{hasDays ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => Math.round(v / 100).toLocaleString()}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
formatter={(value: number) => [formatCurrency(value, "ETB"), "Revenue"]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenueMinor"
|
||||
name="Revenue"
|
||||
stroke={palette.sequential}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Confirmed Bookings */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Daily Confirmed Bookings
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · how many bookings were confirmed each day.
|
||||
</p>
|
||||
{hasDays ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="bookings"
|
||||
name="Bookings"
|
||||
fill={palette.sequential}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Status Distribution */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Booking Status Distribution
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · every booking made in the range, by current status.
|
||||
</p>
|
||||
{statusRows.length > 0 ? (
|
||||
<>
|
||||
{/* Legend with text labels and counts — the palette's light-mode contrast is
|
||||
validated only with that relief in place. */}
|
||||
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap mb-3">
|
||||
{statusRows.map((s) => (
|
||||
<div key={s.name} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: s.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.name} · {s.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusRows}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={85}
|
||||
paddingAngle={2}
|
||||
stroke={palette.surface}
|
||||
strokeWidth={2}
|
||||
>
|
||||
{statusRows.map((s) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Payment Methods
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · which method each booking used. “Unknown” means no
|
||||
payment was started.
|
||||
</p>
|
||||
{methodRows.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={38 * methodRows.length + 32}>
|
||||
<BarChart
|
||||
data={methodRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 44, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={120}
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
name="Bookings"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="count"
|
||||
position="right"
|
||||
fill={palette.textMuted}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string, string> = { 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<PassengerOverview>({
|
||||
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<string, number | string> = { 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 (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<p className="text-sm">Loading fleet passenger overview…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card py-12 text-center">
|
||||
<p className="text-sm text-red-500">Failed to load the fleet passenger overview.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select a schedule above to load its report directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.totals.scheduleCount === 0) {
|
||||
return (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Users className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No departures on record to summarise</p>
|
||||
<p className="text-xs mt-1">
|
||||
Select a schedule above to load its passengers report
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { window: win, totals } = data;
|
||||
const groupShare =
|
||||
totals.totalPassengers > 0
|
||||
? Math.round((totals.groupPassengers / totals.totalPassengers) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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. */}
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarClock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
All schedules · {formatWindow(win.from, win.to)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window totals */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Passengers</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{totals.totalPassengers}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Confirmed and boarded</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Departures</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{totals.scheduleCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">In this window</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Travelling in groups</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{totals.groupPassengers}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{groupShare}% of passengers, on multi-seat bookings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1 — Passengers per departure day */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Passengers by departure day
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
How many people travelled each day across every train in the window.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passengers"
|
||||
name="Passengers"
|
||||
fill={palette.sequential}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 2 — Nationality split */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Passengers by nationality
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Taken from passport country, or Ethiopian where a national ID was used.
|
||||
“Unknown” means neither was recorded.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={38 * nationalityRows.length + 32}>
|
||||
<BarChart
|
||||
data={nationalityRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 44, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="nationality"
|
||||
width={110}
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passengers"
|
||||
name="Passengers"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
>
|
||||
{/* Values printed on the bars — the palette's light-mode contrast is
|
||||
validated only with numeric relief in place. */}
|
||||
<LabelList
|
||||
dataKey="passengers"
|
||||
position="right"
|
||||
fill={palette.textMuted}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 4 — Busiest origin → destination pairs */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Busiest routes
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Where people actually travelled from and to — not the train's own
|
||||
endpoints, but each booking's.
|
||||
</p>
|
||||
<ResponsiveContainer width="100%" height={38 * routeRows.length + 32}>
|
||||
<BarChart
|
||||
data={routeRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 44, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={170}
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passengers"
|
||||
name="Passengers"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="passengers"
|
||||
position="right"
|
||||
fill={palette.textMuted}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 — Passenger category mix */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Adult and child mix
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
The whole bar is every passenger in the window, split by fare category.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap mb-3">
|
||||
{categoriesPresent.map((category) => {
|
||||
const count =
|
||||
data.byCategory.find((c) => c.category === category)?.passengers ?? 0;
|
||||
return (
|
||||
<div key={category} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: categoryColor(category) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{CATEGORY_LABELS[category] ?? category} · {count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={64}>
|
||||
<BarChart
|
||||
data={categoryRow}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis type="category" dataKey="name" hide />
|
||||
<Tooltip contentStyle={tooltipStyle} labelStyle={{ display: "none" }} />
|
||||
{categoriesPresent.map((category) => (
|
||||
<Bar
|
||||
key={category}
|
||||
dataKey={category}
|
||||
name={CATEGORY_LABELS[category] ?? category}
|
||||
stackId="mix"
|
||||
fill={categoryColor(category)}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* The same numbers as exact figures, and the picker */}
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Schedules in window
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The exact numbers behind the charts, one row per departure. Click a row to
|
||||
open that train's full passengers report.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Departure", "Train", "Route", "Passengers"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.schedules.map((s) => (
|
||||
<tr
|
||||
key={s.scheduleId}
|
||||
onClick={() => onSelectSchedule(s.scheduleId)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{new Date(s.departureAt).toLocaleString("en-GB", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-medium">
|
||||
{s.trainNumber}
|
||||
{s.isPackage && (
|
||||
<span className="text-muted-foreground text-xs"> (package)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
{s.originStation} → {s.destinationStation}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{s.passengers}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SeatStatusOverview>({
|
||||
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 (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<p className="text-sm">Loading fleet seat overview…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card py-12 text-center">
|
||||
<p className="text-sm text-red-500">Failed to load the fleet seat overview.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select a schedule above to load its report directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.totals.scheduleCount === 0) {
|
||||
return (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No departures on record to summarise</p>
|
||||
<p className="text-xs mt-1">Select a schedule above to load its seat status report</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { window: win, totals } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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. */}
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarClock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
All schedules · {formatWindow(win.from, win.to)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Load factor</span>
|
||||
<span className="font-semibold tabular-nums text-foreground">
|
||||
{totals.loadFactorPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window totals. Distinct wording from the per-schedule summary cards so the two
|
||||
are never mistaken for each other. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={tile.label} className="card">
|
||||
<div className="flex items-start gap-2">
|
||||
{tile.slot !== -2 && (
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full mt-1.5 shrink-0"
|
||||
style={{ backgroundColor: seriesColor(tile.slot) }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">{tile.label}</p>
|
||||
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||
{tile.value}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{tile.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Seat mix per departure day */}
|
||||
<div className="card">
|
||||
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Seat mix by departure day
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totals.sellableSeats} sellable seats in window
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Legend carries visible text labels — the palette's light-mode contrast is
|
||||
validated only with that relief in place. */}
|
||||
<div className="flex items-center gap-4 flex-wrap mb-3">
|
||||
{INVENTORY_SERIES.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: seriesColor(s.slot) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={{
|
||||
background: palette.tooltipBg,
|
||||
border: `1px solid ${palette.tooltipBorder}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
{INVENTORY_SERIES.map((s) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stackId="seats"
|
||||
fill={seriesColor(s.slot)}
|
||||
radius={s.key === "available" ? [3, 3, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Load factor per schedule — doubles as the picker */}
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Load factor by departure
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<ResponsiveContainer width="100%" height={44 * scheduleRows.length + 32}>
|
||||
<BarChart
|
||||
data={scheduleRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 40, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
unit="%"
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={130}
|
||||
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={{
|
||||
background: palette.tooltipBg,
|
||||
border: `1px solid ${palette.tooltipBorder}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
formatter={(value: number, _name, entry: any) => [
|
||||
`${value}% · ${entry?.payload?.paid ?? 0} of ${entry?.payload?.sellableSeats ?? 0} seats`,
|
||||
"Load factor",
|
||||
]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="loadFactorPercent"
|
||||
name="Load factor"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
cursor="pointer"
|
||||
onClick={(entry: any) => {
|
||||
const id = entry?.payload?.scheduleId ?? entry?.scheduleId;
|
||||
if (id) onSelectSchedule(id);
|
||||
}}
|
||||
>
|
||||
{scheduleRows.map((row) => (
|
||||
<Cell key={row.scheduleId} fill={palette.sequential} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Schedules in window
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The exact numbers behind the charts, one row per departure. Click a row to
|
||||
open that train's full seat status report.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
"Departure",
|
||||
"Train",
|
||||
"Route",
|
||||
"Sellable",
|
||||
"Paid",
|
||||
"Unpaid",
|
||||
"Blocked",
|
||||
"Available",
|
||||
"Load",
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.schedules.map((s) => (
|
||||
<tr
|
||||
key={s.scheduleId}
|
||||
onClick={() => onSelectSchedule(s.scheduleId)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{new Date(s.departureAt).toLocaleString("en-GB", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-medium">
|
||||
{s.trainNumber}
|
||||
{s.isPackage && (
|
||||
<span className="text-muted-foreground text-xs"> (package)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
{s.originStation} → {s.destinationStation}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{s.sellableSeats}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.paid}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.unpaid}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.blocked}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{s.available}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{s.loadFactorPercent}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -380,7 +380,11 @@ export const verifaydaApi = {
|
||||
// Audit API
|
||||
export const auditApi = {
|
||||
getLogs: async (params?: any) => {
|
||||
const query = new URLSearchParams(params as Record<string, string>).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<any>(`/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<any>(`/audit/logs/${id}`),
|
||||
getVocabulary: () => apiClient.get<any>('/audit/vocabulary'),
|
||||
};
|
||||
|
||||
// Live Tracking API
|
||||
|
||||
@@ -18,7 +18,8 @@ interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
/** `identifier` may be an email, phone number or username — always sent as `email`. */
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setUser: (user: AdminUser, token: string) => void;
|
||||
initialize: () => void;
|
||||
@@ -52,9 +53,10 @@ export const useAuthStore = create<AuthState>((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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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<string | null>(null);
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpMessage, setOtpMessage] = useState<string | null>(null);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [pushMessage, setPushMessage] = useState<string | null>(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<any>(`/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<AmountQuote>({
|
||||
queryKey: ["excessBaggageAmount", token, selectedMethod],
|
||||
queryFn: () =>
|
||||
apiClient.get<AmountQuote>(
|
||||
`/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<any>(`/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<any>(`/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() {
|
||||
<div className="card space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Amount due</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{currency} {amountDisplay}
|
||||
</span>
|
||||
{amountLabel ? (
|
||||
<span className="text-2xl font-bold text-primary">{amountLabel}</span>
|
||||
) : quoteError ? (
|
||||
<span className="text-2xl font-bold text-gray-400">—</span>
|
||||
) : (
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
{selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate
|
||||
</p>
|
||||
)}
|
||||
{quoteError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||
{(quoteError as any)?.response?.data?.message ??
|
||||
"This payment method is unavailable right now. Please choose another."}
|
||||
</p>
|
||||
)}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Weight</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{charge.excessWeightKg ?? "—"} kg</span>
|
||||
@@ -131,7 +352,7 @@ export default function ExcessBaggagePayPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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() {
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
disabled={!selectedMethod || isProcessing || awaitingQuote}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : quoteError ? (
|
||||
"Choose another payment method"
|
||||
) : awaitingQuote ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${currency} ${amountDisplay}`
|
||||
`Pay ${amountLabel ?? ""}`.trim()
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* CBE bill — show the number; confirmation only ever comes from the status poll */}
|
||||
{billAction && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{billAction.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
|
||||
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
|
||||
{billAction.billReference}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyBillReference}
|
||||
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
|
||||
title="Copy bill number"
|
||||
>
|
||||
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
{billCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
|
||||
<p>
|
||||
Amount: <span className="font-semibold">ETB {formatAmount(chargeAmount, "ETB")}</span>
|
||||
</p>
|
||||
{billAction.expiresAt && (
|
||||
<p>
|
||||
Pay before:{" "}
|
||||
<span className="font-semibold">
|
||||
{new Date(billAction.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
Waiting for payment confirmation — this page updates automatically once CBE
|
||||
confirms your payment.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBillAction(null)}
|
||||
className="btn-secondary w-full py-2.5 mt-4"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
|
||||
{pushMessage && (
|
||||
<div className="card flex items-start gap-3">
|
||||
<Smartphone className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">Check your phone</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{pushMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
|
||||
{phoneModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Smartphone className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{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."}
|
||||
</p>
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={payerMobile}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {phoneError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setPhoneModalOpen(false)}
|
||||
className="btn-secondary flex-1 py-2.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submitPhone}
|
||||
disabled={!payerMobile.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CAC Bank OTP entry */}
|
||||
{otpModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{otpMessage}</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={otpCode}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {otpError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setOtpModalOpen(false)}
|
||||
disabled={otpMutation.isPending}
|
||||
className="btn-secondary flex-1 py-2.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
|
||||
disabled={otpMutation.isPending || !otpCode.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{otpMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
|
||||
</span>
|
||||
) : (
|
||||
"Confirm payment"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en" dir="ltr" suppressHydrationWarning>
|
||||
<html lang="en" dir="ltr" translate="no" className="notranslate" suppressHydrationWarning>
|
||||
<head>
|
||||
<JsonLd data={organizationSchema} />
|
||||
<JsonLd data={websiteSchema} />
|
||||
|
||||
@@ -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<string | null>(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<string | null>(null);
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [otpMessage, setOtpMessage] = useState<string | null>(null);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [pushMessage, setPushMessage] = useState<string | null>(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<any>(`/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<AmountQuote>({
|
||||
queryKey: ["supplementaryAmount", token, selectedMethod],
|
||||
queryFn: () =>
|
||||
apiClient.get<AmountQuote>(
|
||||
`/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<any>(`/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<any>(`/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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
@@ -122,8 +328,25 @@ export default function PayBalancePage() {
|
||||
)}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
|
||||
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
|
||||
{amountLabel ? (
|
||||
<span className="text-2xl font-bold text-primary">{amountLabel}</span>
|
||||
) : quoteError ? (
|
||||
<span className="text-2xl font-bold text-gray-400">—</span>
|
||||
) : (
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
{selectedMethod && quoteReady && displayCurrency !== chargeCurrency && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Converted from {chargeCurrency} {formatAmount(chargeAmount, chargeCurrency)} at today's rate
|
||||
</p>
|
||||
)}
|
||||
{quoteError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 text-right">
|
||||
{(quoteError as any)?.response?.data?.message ??
|
||||
"This payment method is unavailable right now. Please choose another."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment methods */}
|
||||
@@ -136,7 +359,7 @@ export default function PayBalancePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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() {
|
||||
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
disabled={!selectedMethod || isProcessing || awaitingQuote}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : quoteError ? (
|
||||
"Choose another payment method"
|
||||
) : awaitingQuote ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${currency} ${amountDisplay}`
|
||||
`Pay ${amountLabel ?? ""}`.trim()
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
|
||||
|
||||
{/* CBE bill — show the number; confirmation only ever comes from the status poll */}
|
||||
{billAction && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Pay at CBE</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{billAction.instructions ??
|
||||
"Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3">
|
||||
<span className="font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100 select-all">
|
||||
{billAction.billReference}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyBillReference}
|
||||
className="btn-secondary px-3 py-2 flex items-center gap-1 text-sm"
|
||||
title="Copy bill number"
|
||||
>
|
||||
{billCopied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
{billCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-3 space-y-1">
|
||||
<p>
|
||||
Amount: <span className="font-semibold">ETB {formatAmount(chargeAmount, "ETB")}</span>
|
||||
</p>
|
||||
{billAction.expiresAt && (
|
||||
<p>
|
||||
Pay before:{" "}
|
||||
<span className="font-semibold">
|
||||
{new Date(billAction.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
Waiting for payment confirmation — this page updates automatically once CBE
|
||||
confirms your payment.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBillAction(null)}
|
||||
className="btn-secondary w-full py-2.5 mt-4"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* eBirr: the PIN prompt is on the payer's handset — nothing to navigate to. */}
|
||||
{pushMessage && (
|
||||
<div className="card flex items-start gap-3">
|
||||
<Smartphone className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">Check your phone</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{pushMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */}
|
||||
{phoneModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Smartphone className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Your mobile number</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{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."}
|
||||
</p>
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={payerMobile}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {phoneError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={() => setPhoneModalOpen(false)} className="btn-secondary flex-1 py-2.5">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submitPhone}
|
||||
disabled={!payerMobile.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CAC Bank OTP entry */}
|
||||
{otpModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">Enter OTP</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{otpMessage}</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={otpCode}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-2">⚠️ {otpError}</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setOtpModalOpen(false)}
|
||||
disabled={otpMutation.isPending}
|
||||
className="btn-secondary flex-1 py-2.5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => otpCode.trim() && otpMutation.mutate(otpCode.trim())}
|
||||
disabled={otpMutation.isPending || !otpCode.trim()}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{otpMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Verifying...
|
||||
</span>
|
||||
) : (
|
||||
"Confirm payment"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user