diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index c579eb388..16ee9cd57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,5 +1,17 @@ # Copy to .env for local/docker compose (not committed). PORT=3001 +# @tria-plc/auditlog's client interceptor stamps every AuditLog row's +# `application` from this env var directly, bypassing MezgebModule.forRoot's +# applicationName option (package quirk). audit.controller.ts reads the same +# var when filtering reads, so this can be anything as long as it's set. +APPLICATION_NAME=freight-api +# Also required for @tria-plc/auditlog: its producer (AuditClientModule) +# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's +# rmqUrl option ever runs, so only an env var reaches it — an in-code +# override is too late. Without this, audit events are silently dropped +# (no error, nothing published). Point it at whatever broker/vhost your +# RabbitMQ actually has a user provisioned on. +RABBITMQ_URL=amqp://localhost:5672 # GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables. GT06_TCP_PORT=5023 DB_HOST=localhost diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 73e19709e..adb92c3b0 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,6 +16,7 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; +import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -105,9 +106,14 @@ import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; +import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; +if (!process.env.APPLICATION_NAME) { + process.env.APPLICATION_NAME = "freight"; +} + @Module({ imports: [ ConfigModule.forRoot({ @@ -155,6 +161,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar return dataSource; }, }), + // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). + // Must come after TypeOrmModule above so it picks up this app's DataSource. + // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL + // does: the dev broker only provisions the `edr` user on the `payment` + // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset + // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). + MezgebModule.forRoot({ + applicationName: "freight-api", + rmqUrl: + process.env.RABBITMQ_URL ?? + process.env.PAYMENT_RABBITMQ_URL ?? + "amqp://localhost:5672", + }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -227,6 +246,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar VerifaydaModule, FleetHistoryModule, AiModule, + AuditModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index f699b193d..5de529d15 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; +import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; + +// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — +// the glob below only matches this app's own src/**/*.entity.ts. +const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions { return { ...buildConnectionOptions(), schema: "public", - entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], + entities: [ + __dirname + "/../**/*.entity.{ts,js}", + ...iamEntities, + ...auditEntities, + ], migrations: [], }; } diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index a4fbefdd2..c9a718f5d 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,6 +10,7 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; +import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; @@ -160,6 +161,13 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); + // Audit listener: consumes the RMQ events MezgebModule's client interceptor + // (app.module.ts) emits and persists them via the AuditLogController / + // AuditLogCommandController @EventPattern handlers. Same queue config the + // client side uses, reused from the package so the two never drift apart. + app.connectMicroservice(getAuditLoggerConfig()); + await app.startAllMicroservices(); + const config = new DocumentBuilder() .setTitle("EDR Freight API") .setDescription("API for the EDR Freight Management application") diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..a7c8782b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AuditService } from "./audit.service"; + +@ApiTags("audit") +@Controller("audit") +@BookingStaff(FREIGHT_PERMS.audit.view) +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get("logs") + @ApiOperation({ summary: "List freight-api audit log commands" }) + @ApiQuery({ name: "skip", type: Number, required: false }) + @ApiQuery({ name: "take", type: Number, required: false }) + list(@Query("skip") skip?: string, @Query("take") take?: string) { + // Same fallback chain @tria-plc/auditlog's client interceptor uses to + // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) + // — reading it here instead of a hardcoded literal means this can't + // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME + // actually is at runtime. + const application = + process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; + return this.auditService.list( + application, + skip !== undefined ? parseInt(skip, 10) : undefined, + take !== undefined ? parseInt(take, 10) : undefined, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..635973fc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +import { AuditController } from "./audit.controller"; +import { AuditService } from "./audit.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLogCommand])], + controllers: [AuditController], + providers: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000..8bc792591 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { AuditLogCommand } from "@tria-plc/auditlog"; + +export interface AuditLogListResult { + count: number; + items: AuditLogCommand[]; +} + +/** + * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's + * @BookingStaff — the package's own AuditLogCommandController (mounted at + * /api/audit-log-commands) ships with no guards at all, so it can't be used + * directly for a permission-gated UI. Query mirrors the package's + * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. + */ +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLogCommand) + private readonly auditLogCommandRepository: Repository, + ) {} + + async list( + application: string, + skip = 0, + take = 10, + ): Promise { + const [items, count] = await this.auditLogCommandRepository + .createQueryBuilder("audit_log_commands") + .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", + { application }, + ) + .andWhere( + "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", + { status: "Commit" }, + ) + .select([ + "audit_log_commands.id", + "audit_log_commands.createdAt", + "audit_log_commands.deletedAt", + "audit_log_commands.entityName", + "audit_log_commands.queryMethod", + "audit_log_commands.changes", + "audit_log_commands.payload", + "auditLog.id", + "auditLog.user", + ]) + .addOrderBy("audit_log_commands.createdAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + + return { count, items }; + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 60f173596..4cd7249aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -10,6 +10,7 @@ import { import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; +import { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -1192,6 +1193,17 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against + // at any CBE channel. Persist it on the booking so it survives the initiate response and + // shows on the booking/contract everywhere. The payment service reissues the same reference + // while the bill stays open, so re-initiating overwrites with an identical value. + const billReference = result.response.clientAction?.billReference; + if (billReference && invoice.source === Freight.InvoiceSource.Booking) { + await this.dataSource + .getRepository(Booking) + .update({ id: invoice.sourceId }, { pnrCode: billReference }); + } + // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index c30a2ebda..0c44e704d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -366,6 +366,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), + perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment @@ -700,6 +701,9 @@ export const FREIGHT_PERMS = { manage: 'edr_freight_app:settings:dropdown:manage', }, }, + audit: { + view: 'edr_freight_app:audit:view', + }, staff: { roles: { view: 'edr_freight_app:staff:roles:view', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6e0b5759f..462414480 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ import { FileSignature, FileText, Hammer, + History, LayoutDashboard, LayoutGrid, MapPin, @@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; +import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { @@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, { label: "Configuration", href: "/dashboard/configuration", @@ -1595,6 +1603,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/audit-logs", + meta: { + title: "Audit Logs", + subtitle: "Request and entity-level activity recorded across the freight API", + }, + }, { prefix: "/dashboard/configuration/contract-validity-periods", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d8993df04..dbeb641cf 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -309,6 +309,10 @@ export const URL_CONSTANTS = { SUMMARY: "/payments/summary", }, + AUDIT: { + LOGS: "/audit/logs", + }, + LOCOMOTIVES: { BASE: "/locomotives", BY_ID: (id: string) => `/locomotives/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc70c95a2..9596dc4de 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -276,6 +276,9 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:dropdown:manage", }, }, + audit: { + view: "edr_freight_app:audit:view", + }, staff: { roles: { view: "edr_freight_app:staff:roles:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx new file mode 100644 index 000000000..d4451c2f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx @@ -0,0 +1,185 @@ +import { Badge, Box, Card, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { + AuditLogRow, + AuditQueryMethod, + AuditUser, +} from "@/services/audit.service"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +const ACTION_LABELS: Record = { + INSERT: "Created", + UPDATE: "Updated", + DELETE: "Deleted", + INSERT_CHILD: "Linked child", + DELETE_CHILD: "Unlinked child", +}; + +const ACTION_COLORS: Record = { + INSERT: "edr-green", + UPDATE: "yellow", + DELETE: "red", + INSERT_CHILD: "indigo", + DELETE_CHILD: "gray", +}; + +function formatDateTime(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// The producer (@tria-plc/auditlog's ClientLoggerInterceptor) builds +// `name` from `${auditUser?.firstName} ${auditUser?.lastName}` — this app's +// user model only has a single `name` field, and unauthenticated/customer +// flows (e.g. Fayda verification) have no auditUser at all, so this literal +// "undefined undefined" ends up stored as-is. Filter it back out on render +// rather than showing raw garbage. +function formatUser(user: AuditUser | null | undefined): string { + const name = user?.name; + if (typeof name === "string" && /^undefined(\s+undefined)?$/.test(name.trim())) { + return "—"; + } + return name ?? user?.id ?? "—"; +} + +function summarize(row: AuditLogRow): string { + if (row.changes?.length) { + return row.changes + .slice(0, 2) + .map((c) => c.field) + .join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : ""); + } + if (row.payload) { + return row.payload.name ?? row.payload.title ?? row.payload.id ?? "—"; + } + return "—"; +} + +const tableHeader = + "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; + +export default function AuditLogsPage() { + const { pagination, setPagination } = usePagination({ pageSize: 20 }); + + const filter = { + skip: pagination.pageIndex * pagination.pageSize, + take: pagination.pageSize, + }; + + const { data, isLoading, isError } = useQuery( + api.audit.list.queryOptions({ input: { filter } }), + ); + + const rows = data?.items ?? []; + const total = data?.count ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = [ + { + id: "time", + header: () => Time, + cell: ({ row }) => ( + + {formatDateTime(row.original.createdAt)} + + ), + }, + { + id: "action", + header: () => Action, + cell: ({ row }) => ( + + {ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod} + + ), + }, + { + id: "entity", + header: () => Entity, + cell: ({ row }) => ( + + {row.original.entityName} + + ), + }, + { + id: "user", + header: () => User, + cell: ({ row }) => ( + + {formatUser(row.original.auditLog?.user)} + + ), + }, + { + id: "summary", + header: () => Summary, + cell: ({ row }) => ( + + {summarize(row.original)} + + ), + }, + ]; + + return ( + + + + + + + + {total} record{total !== 1 ? "s" : ""} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a2d5f1b76..2aa864610 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -163,6 +163,11 @@ import { type SaveLocomotivePayload, } from "./locomotives.service"; import { overviewService } from "./overview.service"; +import { + auditService, + type AuditLogListFilter, + type PaginatedAuditLogs, +} from "./audit.service"; import { reportsService } from "./reports.service"; import type { ReportQueryInput, ReportResult } from "@/types/reports"; import { @@ -2136,6 +2141,15 @@ export const api = { ), }, + audit: { + list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>( + "audit", + "list", + ({ filter }) => auditService.list(filter), + ({ filter }) => ["audit", "list", filter ?? {}], + ), + }, + signatures: { mySignature: endpoint( "me", diff --git a/apps/edr-freight-web/backoffice/src/services/audit.service.ts b/apps/edr-freight-web/backoffice/src/services/audit.service.ts new file mode 100644 index 000000000..498d1c77c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/audit.service.ts @@ -0,0 +1,61 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const A = URL_CONSTANTS.AUDIT; + +// Shape from @tria-plc/auditlog's AuditLogCommandController — see +// local-packages/FRONTEND_GUIDE.md. +export type AuditQueryMethod = + | "INSERT" + | "UPDATE" + | "DELETE" + | "INSERT_CHILD" + | "DELETE_CHILD"; + +export interface AuditFieldChange { + field: string; + from: unknown; + to: unknown; +} + +export interface AuditUser { + id?: string; + name?: string; + organizationId?: string; + organizationName?: string; + [key: string]: unknown; +} + +export interface AuditLogRow { + id?: string; + createdAt: string; + deletedAt?: string | null; + entityName: string; + queryMethod: AuditQueryMethod; + changes?: AuditFieldChange[] | null; + payload?: { name?: string; title?: string; id?: string } | null; + auditLog?: { id?: string; user?: AuditUser | null }; +} + +export interface AuditLogListFilter { + skip?: number; + take?: number; +} + +export interface PaginatedAuditLogs { + items: AuditLogRow[]; + count: number; +} + +export const auditService = { + list: async (filter?: AuditLogListFilter): Promise => { + const params: Record = { + skip: filter?.skip, + take: filter?.take, + }; + const response = await client.get(A.LOGS, { params }); + const data = unwrap(response.data) as PaginatedAuditLogs; + return { items: data.items ?? [], count: data.count ?? 0 }; + }, +}; diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts index 715184424..acbfef4ee 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -24,7 +24,11 @@ describe("IntentsService CBE_BILL", () => { let repository: jest.Mocked< Pick< IntentsRepository, - "create" | "findById" | "findByIdempotencyKey" | "update" + | "create" + | "findById" + | "findByIdempotencyKey" + | "findAllByReference" + | "update" > >; let billReferenceService: { generate: jest.Mock }; @@ -46,6 +50,7 @@ describe("IntentsService CBE_BILL", () => { create: jest.fn(async (data) => ({ id: "intent-1", ...data })), findById: jest.fn(), findByIdempotencyKey: jest.fn().mockResolvedValue(null), + findAllByReference: jest.fn().mockResolvedValue([]), update: jest.fn(), } as never; billReferenceService = { @@ -79,6 +84,47 @@ describe("IntentsService CBE_BILL", () => { ); }); + it("reuses the open bill instead of minting a second reference", async () => { + repository.findAllByReference.mockResolvedValue([ + { + id: "intent-1", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + billReference: "000100000015", + amountMinor: 1500, + currency: "ETB", + clientAction: { + type: "SHOW_BILL_REFERENCE", + billReference: "000100000015", + }, + }, + ] as never); + + const snapshot = await service.initiate(request); + + expect(snapshot.billReference).toBe("000100000015"); + expect(billReferenceService.generate).not.toHaveBeenCalled(); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it("mints a new bill when the amount changed", async () => { + repository.findAllByReference.mockResolvedValue([ + { + id: "intent-1", + provider: ProviderMethod.CBE_BILL, + status: ProviderPaymentStatus.REQUIRES_ACTION, + billReference: "000100000015", + amountMinor: 900, + currency: "ETB", + }, + ] as never); + billReferenceService.generate.mockResolvedValue("000100000023"); + + const snapshot = await service.initiate(request); + + expect(snapshot.billReference).toBe("000100000023"); + }); + it("rejects non-ETB currency (plan D8)", async () => { await expect( service.initiate({ ...request, currency: "DJF" }), diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index c0b246b83..0f5b41f4e 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -163,6 +163,32 @@ export class IntentsService { ); } + // The bill reference is issued ONCE per order: the domain app persists it (freight stores it + // as the booking's PNR) and the payer may already have written it down, so re-initiating the + // same open bill must hand back the same number. A different amount/currency means a + // different debt — /cbe/payment verifies the debited amount against the intent — so that + // case mints a fresh bill instead of silently repricing an outstanding one. + const open = ( + await this.intentsRepository.findAllByReference( + request.service, + request.referenceType, + request.referenceId, + ) + ).find( + (i) => + i.provider === ProviderMethod.CBE_BILL && + i.status === ProviderPaymentStatus.REQUIRES_ACTION && + !!i.billReference && + i.amountMinor === request.amountMinor && + i.currency === request.currency, + ); + if (open) { + this.logger.log( + `intent ${open.id} reused for ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${open.billReference})`, + ); + return this.toSnapshot(open); + } + const merchantOrderId = createMerchantOrderId(); const billReference = await this.billReferenceService.generate(); // expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider