From 5c2afe3454a3fc2ff30eaa9174415e8c2b8cb8dd Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 07:30:28 +0000 Subject: [PATCH 1/3] fix(notifications): guard emitNew/emitUnreadCount against no WS server @WebSocketServer() only wires `server` once the WS adapter attaches to a running HTTP listener. It never does under NestFactory.createApplicationContext (scripts, one-off jobs) -- confirmed live tonight, when the EIMS self-test registration's failure alert crashed with "Cannot read properties of null (reading 'to')" instead of just logging that no socket was available. The registration result itself was unaffected (postSigned already resolved, the EimsApiException was correctly re-thrown), but the crash happened inside an await'd call in the same chain -- in a context where it wasn't caught, it would have masked whatever result the caller actually cared about. Both push methods now skip and log at debug level when no server is attached, since the notification row is already persisted by the time they're called -- a missing socket just means "no live push this time", not a reason to lose the caller's own outcome. `server` drops its `!` non-null assertion to match. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications.gateway.spec.ts | 29 +++++++++++++++++++ .../notifications.gateway.ts | 18 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts new file mode 100644 index 000000000..bc79d8acb --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.spec.ts @@ -0,0 +1,29 @@ +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +const gateway = () => new NotificationsGateway({} as WsAuthService); + +describe("NotificationsGateway", () => { + it("skips emitNew rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow(); + }); + + it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => { + const g = gateway(); + expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow(); + }); + + it("pushes to the user's room once a server is attached", () => { + const g = gateway(); + const emit = jest.fn(); + const to = jest.fn().mockReturnValue({ emit }); + (g as unknown as { server: { to: typeof to } }).server = { to }; + + g.emitNew("user-1", { id: "n-1" } as never, 3); + + expect(to).toHaveBeenCalledWith("user:user-1"); + expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" }); + expect(emit).toHaveBeenCalledWith("notification:unread-count", 3); + }); +}); diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index 0c4704170..4658dfac0 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service"; export class NotificationsGateway implements OnGatewayConnection { private readonly logger = new Logger(NotificationsGateway.name); + // Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP + // listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`. @WebSocketServer() - private readonly server!: Server; + private readonly server?: Server; constructor(private readonly wsAuth: WsAuthService) {} @@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push a freshly-created notification + the new unread count to a user. */ emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + if (!this.server) return this.skip("emitNew"); const room = this.server.to(this.room(userId)); room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); @@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection { /** Push only an updated unread count (e.g. after a read on another tab). */ emitUnreadCount(userId: string, unreadCount: number): void { + if (!this.server) return this.skip("emitUnreadCount"); this.server .to(this.room(userId)) .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); } + /** + * `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP + * listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not + * for the brief window before `app.listen()` completes in a real boot either. The notification row + * is already persisted by this point (the caller writes it before pushing), so a missing socket + * server just means "no live push this time" — skip it rather than throw and lose the caller's + * own result (e.g. an EIMS registration outcome that already succeeded or failed for real). + */ + private skip(method: string): void { + this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`); + } + private room(userId: string): string { return `user:${userId}`; } From 8d53dc17ce997b52c7d5b8d703105c2290f660a0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 08:08:03 +0000 Subject: [PATCH 2/3] docs(eims): confirm signing algorithm and cert format against MoR's guide Cross-checked our RSA-SHA512 signing and raw-bytes certificate encoding against MoR's own "Guide to Generating and Using Certificate for E-Invoicing" (supplied today). Both were previously documented as our best inference from the Postman collection; the guide names SHA512withRSA explicitly (PKCS#1v1.5, matching Node's createSign default) and its own worked example certificate is byte-for-byte the same Subject:/Issuer: + 3-cert PEM chain text-file format ours is. No behavior change -- the comment now says confirmed, not assumed. Field order, section names, date format and the {request, signature, certificate} envelope in the guide's worked example all match our mapper exactly (order doesn't matter per the guide, but it's a further concordance check). The one guide/live disagreement -- its example shows "NatureOfSupplies": "Goods" where our actual 400 SCHEMA ERROR demanded lowercase "goods"/"service" -- is left as-is: the live, machine-generated schema error outranks a static doc example that may predate a schema change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/eims/eims-signer.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts index babec6b44..b91f306ab 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types"; * * 1. compact `JSON.stringify` of the **inner** request object only, * 2. those exact UTF-8 bytes, - * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding), + * 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not + * assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names + * `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named + * `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default. * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), - * 5. base64 of the certificate file's exact bytes. + * 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own + * worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain + * text-file format ours is, base64'd with no re-encoding. * * The outer `{request, signature, certificate}` envelope is never itself signed, and the request * object is never mutated after serialization. From 2d4da8110b2fe0bbbbba86ea41892e6b65861638 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 12 Aug 2026 14:08:28 +0000 Subject: [PATCH 3/3] eims integration master test complete --- apps/edr-freight-api/.env.example | 13 +- .../edr-freight-api/src/config/eims.config.ts | 28 ++ .../migrations/3450000000000-WidenEimsIrn.ts | 33 +++ .../3460000000000-AddEimsSignedQr.ts | 20 ++ .../3470000000000-EimsCancellation.ts | 26 ++ .../3480000000000-WidenPreviousIrn.ts | 24 ++ .../migrations/3490000000000-EimsReceipts.ts | 34 +++ .../modules/billing/billing.service.spec.ts | 108 ++++++++ .../src/modules/billing/billing.service.ts | 77 ++++-- .../invoice-document.service.spec.ts | 46 ++++ .../documents/invoice-document.service.ts | 34 ++- .../billing/eims-invoice.mapper.spec.ts | 22 +- .../modules/billing/eims-invoice.mapper.ts | 16 +- .../billing/entities/invoice.entity.ts | 36 ++- .../eims/dto/cancel-eims-registration.dto.ts | 19 ++ .../eims/dto/register-sales-receipt.dto.ts | 88 ++++++ .../dto/register-withholding-receipt.dto.ts | 40 +++ .../eims/dto/resolve-eims-registration.dto.ts | 4 +- .../eims/eims-cancellation.service.spec.ts | 153 +++++++++++ .../modules/eims/eims-cancellation.service.ts | 117 ++++++++ .../modules/eims/eims-invoice-context.spec.ts | 118 ++++++++ .../src/modules/eims/eims-invoice-context.ts | 66 ++++- .../eims-invoice-registration.service.spec.ts | 109 +++++++- .../eims/eims-invoice-registration.service.ts | 128 +++++++-- .../modules/eims/eims-invoice-view.util.ts | 27 ++ .../modules/eims/eims-invoice.controller.ts | 55 +++- .../modules/eims/eims-receipt.service.spec.ts | 231 ++++++++++++++++ .../src/modules/eims/eims-receipt.service.ts | 259 ++++++++++++++++++ .../src/modules/eims/eims-receipt.types.ts | 97 +++++++ .../modules/eims/eims-registration.types.ts | 30 ++ .../src/modules/eims/eims-test-fixtures.ts | 6 + .../src/modules/eims/eims.module.ts | 17 +- .../eims/entities/eims-receipt.entity.ts | 68 +++++ .../eims/entities/eims-system-state.entity.ts | 7 +- .../services/train-scheduling.service.ts | 71 +++++ .../src/seed/freight-permissions.registry.ts | 24 +- .../backoffice/src/types/eims.ts | 25 +- pnpm-lock.yaml | 167 +++++------ 38 files changed, 2270 insertions(+), 173 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts create mode 100644 apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts create mode 100644 apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts create mode 100644 apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts create mode 100644 apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/cancel-eims-registration.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/register-withholding-receipt.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-view.util.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts create mode 100644 apps/edr-freight-api/src/modules/eims/entities/eims-receipt.entity.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 2c636eee4..5a145a0db 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -# Required, and deliberately unset: the choice is a tax position, not a default. +# Required, and deliberately unset here: the choice is a tax position, not a default. # MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH -# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env. EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a +# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material. +# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above. +# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types. +EIMS_TAX_CODE_BY_CHARGE_TYPE= +EIMS_TAX_RATE_BY_CHARGE_TYPE= +# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively. +EIMS_EXCISE_BY_CHARGE_TYPE= +EIMS_DISCOUNT_BY_CHARGE_TYPE= # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B # Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index a8a929ca5..cf77072e0 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -89,8 +89,30 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to + * `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax + * treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above + * cannot express that. Values are raw strings; the context builder parses/validates them. + */ + taxCodeByChargeType: Record; + taxRateByChargeType: Record; + /** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge + * types not listed fall back to `exciseTaxValue` / 0 respectively. */ + exciseByChargeType: Record; + discountByChargeType: Record; cashierName: string | null; salesPersonName: string | null; + /** + * TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every + * buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be + * one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead + * of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits. + * Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists. + */ + buyerIdType: string | null; + buyerIdNumber: string | null; } const REQUIRED_VARS = [ @@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => { buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), + taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), + exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), + discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null, + buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null, }, }; diff --git a/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts new file mode 100644 index 000000000..ec3faf9eb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed + * live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as + * `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-` + * prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already + * accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING` + * and the system-wide reservation stuck in-flight with no block/alert (see + * `EimsInvoiceRegistrationService` for the accompanying code fix). + * + * Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or + * length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's + * real production shape — guessing another fixed bound risks the exact same failure again. + */ +export class WidenEimsIrn3450000000000 implements MigrationInterface { + name = "WidenEimsIrn3450000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE text + `); + } + + /** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts new file mode 100644 index 000000000..dedf2d05f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */ +export class AddEimsSignedQr3460000000000 implements MigrationInterface { + name = "AddEimsSignedQr3460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_signed_qr text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_signed_qr + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts new file mode 100644 index 000000000..7092e5518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */ +export class EimsCancellation3470000000000 implements MigrationInterface { + name = "EimsCancellation3470000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8), + ADD COLUMN IF NOT EXISTS eims_cancellation_remark text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_cancelled_at, + DROP COLUMN IF EXISTS eims_cancellation_date, + DROP COLUMN IF EXISTS eims_cancellation_reason_code, + DROP COLUMN IF EXISTS eims_cancellation_remark + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts new file mode 100644 index 000000000..15a343f06 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too + * (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same + * varchar(64) on the next successful registration. + */ +export class WidenPreviousIrn3480000000000 implements MigrationInterface { + name = "WidenPreviousIrn3480000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts new file mode 100644 index 000000000..3d5ab02e9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `freight.eims_receipts` — see `EimsReceipt` entity. */ +export class EimsReceipts3490000000000 implements MigrationInterface { + name = "EimsReceipts3490000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_receipts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id uuid NOT NULL REFERENCES freight.invoices(id), + kind varchar(16) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + receipt_number varchar(64) NOT NULL, + rrn text, + qr text, + ack_status varchar(8), + submitted_at timestamptz, + last_error jsonb, + request jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 576c7b166..d9eef5a43 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); }); @@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, + {} as never, // config ); return { service, defaultManager, txManager, transaction }; }; @@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, manager }; }; @@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => }); }); }); + +describe("BillingService.document", () => { + const invoiceRow = (over: Record = {}) => ({ + id: "inv-1", + invoiceNumber: "INV-20260812-00001", + source: "booking", + sourceId: "booking-1", + status: Freight.InvoiceStatus.Pending, + type: "freight", + currency: "ETB", + subtotalAmount: 100, + taxAmount: 0, + totalAmount: 100, + paidAmount: 0, + balanceAmount: 100, + issuedAt: new Date(2026, 7, 12), + dueAt: new Date(2026, 7, 19), + eimsIrn: null, + eimsSignedQr: null, + company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" }, + ...over, + }); + + const build = (invoice: Record) => { + const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const service = new BillingService( + {} as never, + { findById: jest.fn().mockResolvedValue(invoice) } as never, + { findAll: jest.fn().mockResolvedValue([]) } as never, + {} as never, + {} as never, + {} as never, + { render } as never, + {} as never, + { + get: (key: string) => + key === "eims" + ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } + : undefined, + } as never, // config + ); + return { service, render }; + }; + + it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined(); + expect(model.qrImageUrl).toBeNull(); + }); + + it("shows the buyer's name, TIN and VAT number on every invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" }); + expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" }); + expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" }); + }); + + it("omits the VAT row when the buyer company has none", async () => { + const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } })); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined(); + }); + + it("shows EDR's own seller TIN and VAT number from EIMS config", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" }); + expect(model.summary).toContainEqual({ + label: "Seller VAT No.", + value: "43256663343256663322", + }); + }); + + it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => { + const { service, render } = build( + invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); + expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); + }); +}); 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 6c7e8455d..caa25fbaa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,5 @@ import { Freight, PaymentReferenceType } from "@edr/types"; +import { ConfigService } from "@nestjs/config"; import { BadRequestException, forwardRef, @@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; @@ -161,6 +163,7 @@ export class BillingService { private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, + private readonly config: ConfigService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -384,7 +387,7 @@ export class BillingService { async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "INVOICE"), + await this.toDocumentModel(invoice, "INVOICE"), ); } @@ -397,15 +400,24 @@ export class BillingService { ); } return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "RECEIPT"), + await this.toDocumentModel(invoice, "RECEIPT"), ); } + /** + * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the + * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), + * not a payload we encode ourselves. Wrapped in a data URL, nothing more. + */ + private renderEimsQr(signedQr: string): string { + return `data:image/png;base64,${signedQr}`; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ - private toDocumentModel( + private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, kind: "INVOICE" | "RECEIPT", - ): InvoiceDocumentModel { + ): Promise { const title = invoice.source ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) : "EDR"; @@ -423,6 +435,43 @@ export class BillingService { totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + const summary: InvoiceDocumentModel["summary"] = [ + // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was + // filed against, not just the seller. VatNumber shown only when the company has one. + { label: "Buyer", value: invoice.company?.name ?? null }, + { label: "Buyer TIN", value: invoice.company?.tin ?? null }, + ...(invoice.company?.vatNumber + ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }] + : []), + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ]; + + // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this + // codebase). Shown only when actually configured, same as the buyer VAT row. + const eimsCfg = this.config.get("eims"); + if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); + if (eimsCfg?.invoice?.sellerVatNumber) { + summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + } + + // MoR EIMS reference — only once actually registered, never a placeholder row. + if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + return { kind, title, @@ -430,24 +479,7 @@ export class BillingService { issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, - summary: [ - { label: "Status", value: invoice.status }, - { label: "Type", value: invoice.type }, - { label: "Reference", value: invoice.sourceId }, - { label: "Currency", value: invoice.currency }, - { - label: "Issued", - value: invoice.issuedAt - ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") - : null, - }, - { - label: "Due", - value: invoice.dueAt - ? new Date(invoice.dueAt).toLocaleDateString("en-GB") - : null, - }, - ], + summary, categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ description: l.description ?? l.chargeType, @@ -458,6 +490,7 @@ export class BillingService { currency: l.currency, })), totals, + qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, }; } diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts new file mode 100644 index 000000000..00e590e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -0,0 +1,46 @@ +import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; + +const model = (over: Partial = {}): InvoiceDocumentModel => ({ + kind: "INVOICE", + title: "Freight", + documentNumber: "INV-20260812-00001", + issuedAt: new Date(2026, 7, 12), + status: "PENDING", + currency: "ETB", + summary: [{ label: "Status", value: "PENDING" }], + lines: [], + totals: [{ label: "Total", amount: 100, grand: true }], + ...over, +}); + +describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { + const service = new InvoiceDocumentService({} as never, {} as never); + + it("renders no QR block when qrImageUrl is unset", () => { + const html = service.buildHtml(model()); + expect(html).not.toContain('class="qr"'); + }); + + it("renders the QR image when qrImageUrl is set", () => { + const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + }); + + it("still shows the IRN text row via the ordinary summary grid", () => { + const html = service.buildHtml( + model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }), + ); + expect(html).toContain("EIMS IRN"); + expect(html).toContain("IRN-123"); + }); + + it("widens the summary's right margin only when a QR is present, to clear the QR block", () => { + // "summary-with-qr" also appears in the always-present