From 2644d5e52d3437ccdd015a03ed3ce435b4ce3cf1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 11:42:29 +0000 Subject: [PATCH 1/7] feat(eims): add invoice mapper and signed EIMS transport Map EDR invoices onto the MoR EIMS /v1/register document and add the cryptographic transport needed to talk to core.mor.gov.et. Mapper: DTOs mirror the supplied Postman collection section by section. Tax is resolved per line via a caller-supplied resolver and throws when unresolved -- the app models no tax at all (invoice.taxAmount is always 0, invoice_lines and the rate catalogue carry no fiscal columns), so a zero-rated default would assert a tax position the codebase cannot support. Seller identity, document number, counters and previous IRN are passed in explicitly; the mapper stays pure. Transport: config, credential loading, RSA-SHA512 signing and /auth/login with an in-memory token cache. Signing reproduces the process that produced a working live token -- compact JSON of the inner request only, exact UTF-8 bytes, base64 signature, and base64 of the certificate file's exact bytes with no parsing or re-encoding. Concurrent callers share one login via an in-flight promise. Refresh is deliberately unimplemented: the collection shows an unsigned refresh body but also ships unsigned examples of calls that do require signing, so an expired token re-logs in instead. Errors normalise to EimsApiException carrying only the gateway's own error fields; secrets, signature, certificate and tokens never reach logs. Key and certificate file patterns are gitignored. Nothing calls EIMS automatically and no invoice entity, migration or UI is touched. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 + apps/edr-freight-api/.env.example | 53 +++ apps/edr-freight-api/package.json | 3 +- apps/edr-freight-api/src/app.module.ts | 4 + .../edr-freight-api/src/config/eims.config.ts | 80 ++++ .../billing/eims-invoice.mapper.spec.ts | 214 +++++++++++ .../modules/billing/eims-invoice.mapper.ts | 362 ++++++++++++++++++ .../modules/eims/eims-auth.service.spec.ts | 190 +++++++++ .../src/modules/eims/eims-auth.service.ts | 116 ++++++ .../src/modules/eims/eims-client.service.ts | 67 ++++ .../modules/eims/eims-credentials.provider.ts | 77 ++++ .../modules/eims/eims-signer.service.spec.ts | 118 ++++++ .../src/modules/eims/eims-signer.service.ts | 37 ++ .../src/modules/eims/eims.errors.ts | 89 +++++ .../src/modules/eims/eims.module.ts | 19 + .../src/modules/eims/eims.types.ts | 46 +++ .../edr-freight-api/src/scripts/eims-login.ts | 40 ++ 17 files changed, 1523 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/config/eims.config.ts create mode 100644 apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts create mode 100644 apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auth.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-client.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-signer.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.errors.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.module.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims.types.ts create mode 100644 apps/edr-freight-api/src/scripts/eims-login.ts diff --git a/.gitignore b/.gitignore index 63784b865..8fa8bca90 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,12 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml +# private keys / certificates (EIMS INSA credentials and anything like them) — never commit +*.key +*.pem +*.pem.txt +*.p12 +*.pfx +*.crt +secrets/ +certs/ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..a11b98520 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -126,3 +126,56 @@ EMAIL_QUEUE=email_queue # Shared secret for service-to-service calls (payment microservice <-> freight). # Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. SERVICE_AUTH_TOKEN=change-me + +# ── MoR EIMS e-invoicing (core.mor.gov.et) ───────────────────────────────── +# Disabled by default; every EIMS call fails fast with EIMS_NOT_CONFIGURED until enabled. +EIMS_ENABLED=false +EIMS_BASE_URL=https://core.mor.gov.et +EIMS_CLIENT_ID= +EIMS_CLIENT_SECRET= +EIMS_API_KEY= +EIMS_TIN= +# MoR-issued source-system identifiers (used once invoice registration lands) +EIMS_SYSTEM_NUMBER= +EIMS_SYSTEM_TYPE= +# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file +# patterns are gitignored, but a path outside the working tree is safer still. +# The certificate is transmitted as base64 of this file's exact bytes — do not convert it. +EIMS_PRIVATE_KEY_PATH= +EIMS_CERTIFICATE_PATH= +# Optional tuning +EIMS_HTTP_TIMEOUT_MS=30000 +EIMS_TOKEN_SKEW_SECONDS=45 + +# ── EIMS invoice registration (required only to register invoices) ───────── +# Seller identity: EDR's own legal details are not modelled anywhere in the DB. +# Region and Wereda are MoR *codes* (e.g. 13 / 574), not names. +EIMS_SELLER_LEGAL_NAME= +EIMS_SELLER_VAT_NUMBER= +EIMS_SELLER_PHONE= +EIMS_SELLER_EMAIL= +EIMS_SELLER_REGION= +EIMS_SELLER_WEREDA= +# Optional seller address parts; sent as null when unset. +EIMS_SELLER_CITY= +EIMS_SELLER_SUBCITY= +EIMS_SELLER_HOUSE_NUMBER= +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. +EIMS_TAX_CODE= +EIMS_TAX_RATE_PERCENT= +EIMS_EXCISE_TAX_VALUE=0 +EIMS_INCOME_WITHHOLD_VALUE=0 +EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Document classification and payment presentation. +EIMS_TRANSACTION_TYPE=B2B +EIMS_NATURE_OF_SUPPLIES=Service +EIMS_PAYMENT_MODE=CASH +EIMS_PAYMENT_TERM=IMMIDIATE +EIMS_UNIT_DEFAULT=PCS +# MoR numeric country code for the buyer; our companies store the country name. +EIMS_BUYER_COUNTRY_CODE= +EIMS_CASHIER_NAME= +EIMS_SALESPERSON_NAME= diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c4837c8a7..fb6a0bd31 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", - "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c4c1514ec..50dde1034 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; +import eimsConfig from "./config/eims.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -84,6 +85,7 @@ import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-d //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; @@ -127,6 +129,7 @@ if (!process.env.APPLICATION_NAME) { telebirrConfig, rabbitmqConfig, faydaConfig, + eimsConfig, ], }), ScheduleModule.forRoot(), @@ -248,6 +251,7 @@ if (!process.env.APPLICATION_NAME) { InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + EimsModule, FleetHistoryModule, AiModule, AuditModule, diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts new file mode 100644 index 000000000..37cbbc84b --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -0,0 +1,80 @@ +import { registerAs } from "@nestjs/config"; + +/** + * Ethiopian MoR EIMS e-invoicing gateway. + * + * Disabled by default: with `EIMS_ENABLED=false` the config resolves to a stub and every EIMS + * service throws a clear error on use, so a deployment without credentials still boots. + * + * Secrets (client secret, API key) and the credential file paths live only here and are never + * logged — validation reports missing variable *names*, never their values. + */ +export interface EimsConfig { + enabled: boolean; + baseUrl: string; + clientId: string; + clientSecret: string; + apiKey: string; + tin: string; + /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + systemNumber: string; + systemType: string; + /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ + privateKeyPath: string; + /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ + certificatePath: string; + httpTimeoutMs: number; + /** Re-authenticate this many ms before the access token actually expires. */ + tokenSkewMs: number; +} + +const REQUIRED_VARS = [ + "EIMS_CLIENT_ID", + "EIMS_CLIENT_SECRET", + "EIMS_API_KEY", + "EIMS_TIN", + "EIMS_PRIVATE_KEY_PATH", + "EIMS_CERTIFICATE_PATH", +] as const; + +const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { + if (raw === undefined || raw === "") return fallback; + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +}; + +export default registerAs("eims", (): EimsConfig => { + const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true"; + const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, ""); + const httpTimeoutMs = positiveInt(process.env.EIMS_HTTP_TIMEOUT_MS, 30_000, "EIMS_HTTP_TIMEOUT_MS"); + const tokenSkewMs = + positiveInt(process.env.EIMS_TOKEN_SKEW_SECONDS, 45, "EIMS_TOKEN_SKEW_SECONDS") * 1000; + + const base: EimsConfig = { + enabled, + baseUrl, + clientId: process.env.EIMS_CLIENT_ID ?? "", + clientSecret: process.env.EIMS_CLIENT_SECRET ?? "", + apiKey: process.env.EIMS_API_KEY ?? "", + tin: process.env.EIMS_TIN ?? "", + systemNumber: process.env.EIMS_SYSTEM_NUMBER ?? "", + systemType: process.env.EIMS_SYSTEM_TYPE ?? "", + privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", + certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + httpTimeoutMs, + tokenSkewMs, + }; + + if (!enabled) return base; + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, + ); + } + return base; +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts new file mode 100644 index 000000000..aef4ac163 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -0,0 +1,214 @@ +import { + EimsMapperContext, + EimsMapperInvoice, + EimsSellerDetails, + formatEimsDate, + toEimsInvoice, +} from "./eims-invoice.mapper"; + +const seller: EimsSellerDetails = { + City: null, + Email: "finance@edr.et", + HouseNumber: null, + LegalName: "Ethio-Djibouti Railway S.C.", + Locality: null, + Phone: "0911223344", + Region: "13", + SubCity: null, + Tin: "0016324478", + VatNumber: "3215840010", + Wereda: "574", +}; + +const invoice = (over: Partial = {}): EimsMapperInvoice => ({ + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "11000.00", + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Addis → Djibouti", quantity: "1.00", unitRate: "10000.00", amount: "10000.00" }, + { chargeType: "HAZARD_SURCHARGE", description: null, quantity: "2.00", unitRate: "500.00", amount: "1000.00", metadata: { unit: "CTR" } }, + ], + ...over, +}); + +const context = (over: Partial = {}): EimsMapperContext => ({ + systemNumber: "B0360154BA", + systemType: "SYS", + documentNumber: "24", + invoiceCounter: 7, + previousIrn: "", + cashierName: null, + salesPersonName: null, + transactionType: "B2B", + payment: { mode: "CASH", term: "IMMIDIATE" }, + taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }), + natureOfSupplies: "Service", + unitDefault: "PCS", + incomeWithholdValue: 0, + transactionWithholdValue: 0, + ...over, +}); + +describe("toEimsInvoice", () => { + it("emits the ten EIMS sections with the collection's field names", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + + expect(Object.keys(doc)).toEqual([ + "BuyerDetails", + "DocumentDetails", + "ItemList", + "PaymentDetails", + "ReferenceDetails", + "SellerDetails", + "SourceSystem", + "TransactionType", + "ValueDetails", + "Version", + ]); + expect(doc.Version).toBe("1"); + expect(doc.DocumentDetails).toEqual({ DocumentNumber: "24", Date: "07-08-2026T09:05:03", Type: "INV" }); + expect(doc.SourceSystem.InvoiceCounter).toBe(7); + expect(doc.SellerDetails).toBe(seller); + }); + + it("maps the buyer from the company row and leaves unmodelled fields null", () => { + const doc = toEimsInvoice(invoice(), seller, context()); + + expect(doc.BuyerDetails).toEqual({ + City: null, + Email: "buyer@abc.et", + HouseNumber: "NEW", + IdNumber: null, + IdType: null, + Tin: "0999930000", + LegalName: "ABC Trading PLC", + Phone: "0912345678", + Region: "13", + Country: null, + Zone: "SHA", + Kebele: "03", + VatNumber: "123475885858", + Wereda: "574", + }); + }); + + it("applies per-line tax and totals it into ValueDetails", () => { + const doc = toEimsInvoice( + invoice(), + seller, + context({ + taxForLine: (line) => + line.chargeType === "RAIL_FREIGHT" + ? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 } + : { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 }, + }), + ); + + expect(doc.ItemList[0]).toMatchObject({ + LineNumber: 1, + ItemCode: "RAIL_FREIGHT", + ProductDescription: "Addis → Djibouti", + Quantity: 1, + UnitPrice: 10000, + PreTaxValue: 10000, + TaxCode: "VAT15", + TaxAmount: 1500, + ExciseTaxValue: 0, + TotalLineAmount: 11500, + Unit: "PCS", + NatureOfSupplies: "Service", + HarmonizationCode: null, + }); + expect(doc.ItemList[1]).toMatchObject({ + LineNumber: 2, + ProductDescription: "HAZARD_SURCHARGE", + TaxCode: "EXEMPT", + TaxAmount: 0, + ExciseTaxValue: 50, + TotalLineAmount: 1050, + Unit: "CTR", + }); + expect(doc.ValueDetails).toEqual({ + Discount: null, + ExciseValue: 50, + IncomeWithholdValue: 0, + TaxValue: 1500, + TotalValue: 12550, + TransactionWithholdValue: 0, + InvoiceCurrency: "ETB", + }); + }); + + it("passes PreviousIrn through verbatim and defaults RelatedDocument to null", () => { + expect(toEimsInvoice(invoice(), seller, context()).ReferenceDetails).toEqual({ + PreviousIrn: "", + RelatedDocument: null, + }); + expect( + toEimsInvoice(invoice(), seller, context({ previousIrn: null, relatedDocument: "CN-9" })) + .ReferenceDetails, + ).toEqual({ PreviousIrn: null, RelatedDocument: "CN-9" }); + }); + + it("emits ExchangeRate only when supplied", () => { + expect(toEimsInvoice(invoice(), seller, context()).ValueDetails.ExchangeRate).toBeUndefined(); + + const usd = toEimsInvoice( + invoice({ currency: "USD" }), + seller, + context({ exchangeRate: 132.5 }), + ); + expect(usd.ValueDetails).toMatchObject({ InvoiceCurrency: "USD", ExchangeRate: 132.5 }); + }); + + it("honours a caller-supplied date formatter", () => { + const doc = toEimsInvoice(invoice(), seller, context({ formatDate: () => "2026-08-07T09:05:03Z" })); + expect(doc.DocumentDetails.Date).toBe("2026-08-07T09:05:03Z"); + }); + + it("throws when tax treatment cannot be resolved for a line", () => { + expect(() => + toEimsInvoice( + invoice(), + seller, + context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }), + ), + ).toThrow(/unresolved tax treatment for line 1/); + }); + + it("throws on a missing buyer TIN, no lines, or an unissued invoice", () => { + expect(() => toEimsInvoice(invoice({ company: null }), seller, context())).toThrow(/buyer company TIN/); + expect(() => toEimsInvoice(invoice({ lines: [] }), seller, context())).toThrow(/has no lines/); + expect(() => toEimsInvoice(invoice({ issuedAt: null }), seller, context())).toThrow(/not issued/); + }); + + it("throws when the lines do not sum to the invoice total", () => { + expect(() => toEimsInvoice(invoice({ totalAmount: "9000.00" }), seller, context())).toThrow( + /lines sum to 11000 but the invoice total is 9000/, + ); + }); + + it("throws on a non-ETB invoice with no exchange rate", () => { + expect(() => toEimsInvoice(invoice({ currency: "USD" }), seller, context())).toThrow(/needs an exchangeRate/); + }); +}); + +describe("formatEimsDate", () => { + it("renders the observed dd-MM-yyyyTHH:mm:ss shape with zero padding", () => { + expect(formatEimsDate(new Date(2025, 2, 21, 0, 0, 0))).toBe("21-03-2025T00:00:00"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts new file mode 100644 index 000000000..864d9f829 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -0,0 +1,362 @@ +/** + * Pure mapper from an EDR invoice onto the Ethiopian MoR EIMS registration document + * (`POST https://core.mor.gov.et/v1/register`). + * + * Field names, casing and section layout are taken verbatim from the supplied + * `EimsCoreApiMockCollection2.postman_collection.json`. Note the payload spells the district + * `Wereda` even though the collection *variable* is named `sellerWoreda`. + * + * Scope: mapping only — no HTTP, no signing, no persistence, no counter allocation. Everything + * that does not live on the invoice (document number, counters, previous IRN, seller identity, + * tax treatment) is supplied by the caller and is never guessed here. + * + * Values that the collection only *demonstrates* by example — the date format, the meaning of an + * empty `PreviousIrn`, the `SystemType` enum, `PaymentTerm` values — are treated as observed, not + * authoritative: they are passed through or overridable rather than validated against a fixed set. + */ + +import { round2 } from "./invoice-settlement.util"; + +/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ +const EIMS_VERSION = "1"; + +/** The only `DocumentDetails.Type` observed in the supplied material. */ +const EIMS_DOCUMENT_TYPE = "INV"; + +export interface EimsBuyerDetails { + City: string | null; + Email: string | null; + HouseNumber: string | null; + IdNumber: string | null; + IdType: string | null; + Tin: string; + LegalName: string; + Phone: string | null; + Region: string | null; + Country: string | null; + Zone: string | null; + Kebele: string | null; + VatNumber: string | null; + Wereda: string | null; +} + +export interface EimsSellerDetails { + City: string | null; + Email: string | null; + HouseNumber: string | null; + LegalName: string; + Locality: string | null; + Phone: string | null; + /** MoR region *code* (e.g. "13"), not a region name. */ + Region: string | null; + SubCity: string | null; + Tin: string; + VatNumber: string | null; + /** MoR wereda *code* (e.g. "574"). */ + Wereda: string | null; +} + +export interface EimsDocumentDetails { + DocumentNumber: string; + /** Observed format `dd-MM-yyyyTHH:mm:ss`. Rule seen in the collection: within 3 days of now. */ + Date: string; + Type: string; +} + +export interface EimsInvoiceItem { + Discount: number; + ExciseTaxValue: number; + HarmonizationCode: string | null; + NatureOfSupplies: string; + ItemCode: string; + ProductDescription: string; + PreTaxValue: number; + Quantity: number; + LineNumber: number; + TaxAmount: number; + TaxCode: string; + TotalLineAmount: number; + Unit: string; + UnitPrice: number; +} + +export interface EimsPaymentDetails { + Mode: string; + PaymentTerm: string; +} + +export interface EimsReferenceDetails { + PreviousIrn: string | null; + RelatedDocument: string | null; +} + +export interface EimsSourceSystem { + CashierName: string | null; + InvoiceCounter: number; + SalesPersonName: string | null; + SystemNumber: string; + SystemType: string; +} + +export interface EimsValueDetails { + Discount: number | null; + ExciseValue: number; + IncomeWithholdValue: number; + TaxValue: number; + TotalValue: number; + TransactionWithholdValue: number; + InvoiceCurrency: string; + /** Absent from the register sample, present on the verify response. Emitted only when supplied. */ + ExchangeRate?: number; +} + +export interface EimsInvoiceRequest { + BuyerDetails: EimsBuyerDetails; + DocumentDetails: EimsDocumentDetails; + ItemList: EimsInvoiceItem[]; + PaymentDetails: EimsPaymentDetails; + ReferenceDetails: EimsReferenceDetails; + SellerDetails: EimsSellerDetails; + SourceSystem: EimsSourceSystem; + TransactionType: string; + ValueDetails: EimsValueDetails; + Version: string; +} + +/** `body` of a successful `POST /v1/register`, as observed in the collection. */ +export interface EimsRegisterResponseBody { + irn: string; + ackDate: string; + signedQR: string; + signedInvoice: string; + status: string; + documentNumber: string; + errorMessage: string | null; +} + +/** Numeric columns arrive from pg as strings; every money field is normalised through `num`. */ +export interface EimsMapperLine { + chargeType: string; + description?: string | null; + quantity: number | string; + unitRate: number | string; + amount: number | string; + metadata?: Record | null; +} + +export interface EimsMapperCompany { + name: string; + tin: string; + vatNumber?: string | null; + phone?: string | null; + email?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + country?: string | null; +} + +/** + * Structurally what `BillingService.findById` returns — the only read path that loads the header, + * the buyer company and the lines together. + */ +export interface EimsMapperInvoice { + invoiceNumber: string; + currency: string; + issuedAt?: Date | string | null; + totalAmount: number | string; + company?: EimsMapperCompany | null; + lines: EimsMapperLine[]; +} + +/** + * Tax treatment for a single line. EIMS models `TaxCode`/`TaxAmount`/`ExciseTaxValue` per item, and + * different charge types may eventually be treated differently, so this is resolved per line. + * + * Nothing in this repo can supply it: `Invoice.taxAmount` is hardcoded to 0 with no caller ever + * setting it, `invoice_lines` has no tax column, and the rate catalogue has no fiscal field. That + * is the absence of a tax model, not evidence of zero-rating — hence no default here. + */ +export interface EimsLineTax { + code: string; + ratePercent: number; + exciseTaxValue: number; +} + +export interface EimsMapperContext { + systemNumber: string; + /** Observed values: POS, MAN, CRM, EFD, SYS (the collection prose also mentions ERP). */ + systemType: string; + /** Caller decides the source — our own `invoiceNumber` or a dedicated EIMS sequence. */ + documentNumber: string; + invoiceCounter: number; + /** Passed through verbatim; the collection shows `""` used for an unchained document. */ + previousIrn: string | null; + cashierName: string | null; + salesPersonName: string | null; + /** B2B / B2C — a tax classification, so the caller states it. */ + transactionType: string; + payment: { mode: string; term: string }; + /** Must return a treatment for every line, or throw. */ + taxForLine: (line: EimsMapperLine, lineNumber: number) => EimsLineTax; + natureOfSupplies: string; + /** Used when a line carries no `metadata.unit`. */ + unitDefault: string; + incomeWithholdValue: number; + transactionWithholdValue: number; + /** Null for an ordinary invoice; set only for a real related-document case. */ + relatedDocument?: string | null; + /** MoR numeric country code for the buyer; our DB stores the country name. */ + buyerCountryCode?: string | null; + buyerIdType?: string | null; + buyerIdNumber?: string | null; + buyerCity?: string | null; + /** Required when the invoice currency is not ETB. */ + exchangeRate?: number | null; + invoiceDiscount?: number | null; + /** Override while the observed `dd-MM-yyyyTHH:mm:ss` format is unconfirmed by MoR. */ + formatDate?: (issuedAt: Date) => string; +} + +const num = (v: number | string): number => { + const n = Number(v); + if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`); + return n; +}; + +const pad = (n: number, width = 2): string => String(n).padStart(width, "0"); + +/** Observed EIMS document-date format: `dd-MM-yyyyTHH:mm:ss`, no timezone marker. */ +export const formatEimsDate = (issuedAt: Date): string => + `${pad(issuedAt.getDate())}-${pad(issuedAt.getMonth() + 1)}-${issuedAt.getFullYear()}` + + `T${pad(issuedAt.getHours())}:${pad(issuedAt.getMinutes())}:${pad(issuedAt.getSeconds())}`; + +/** + * Map one loaded invoice onto an EIMS registration document. + * + * Throws rather than emitting a payload EIMS would reject opaquely: missing buyer TIN, no lines, + * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no + * exchange rate. + */ +export function toEimsInvoice( + invoice: EimsMapperInvoice, + seller: EimsSellerDetails, + context: EimsMapperContext, +): EimsInvoiceRequest { + const company = invoice.company; + if (!company || !company.tin?.trim()) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no buyer company TIN`); + } + if (!invoice.lines?.length) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has no lines`); + } + if (!invoice.issuedAt) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} is not issued (issuedAt is null)`); + } + if (invoice.currency !== "ETB" && context.exchangeRate == null) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`, + ); + } + + const issuedAt = invoice.issuedAt instanceof Date ? invoice.issuedAt : new Date(invoice.issuedAt); + if (Number.isNaN(issuedAt.getTime())) { + throw new Error(`EIMS mapping: invoice ${invoice.invoiceNumber} has an unparseable issuedAt`); + } + + const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { + const lineNumber = index + 1; + const tax = context.taxForLine(line, lineNumber); + if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) { + throw new Error( + `EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` + + `on invoice ${invoice.invoiceNumber}`, + ); + } + + const PreTaxValue = round2(num(line.amount)); + const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100); + const ExciseTaxValue = round2(tax.exciseTaxValue); + const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; + + return { + Discount: 0, + ExciseTaxValue, + HarmonizationCode: null, + NatureOfSupplies: context.natureOfSupplies, + ItemCode: line.chargeType, + ProductDescription: line.description?.trim() || line.chargeType, + PreTaxValue, + Quantity: round2(num(line.quantity)), + LineNumber: lineNumber, + TaxAmount, + TaxCode: tax.code, + TotalLineAmount: round2(PreTaxValue + TaxAmount + ExciseTaxValue), + Unit: unit, + UnitPrice: round2(num(line.unitRate)), + }; + }); + + const preTaxTotal = round2(ItemList.reduce((sum, item) => sum + item.PreTaxValue, 0)); + const invoiceTotal = round2(num(invoice.totalAmount)); + if (Math.abs(preTaxTotal - invoiceTotal) > 0.01) { + throw new Error( + `EIMS mapping: invoice ${invoice.invoiceNumber} lines sum to ${preTaxTotal} ` + + `but the invoice total is ${invoiceTotal}`, + ); + } + + const ValueDetails: EimsValueDetails = { + Discount: context.invoiceDiscount ?? null, + ExciseValue: round2(ItemList.reduce((sum, item) => sum + item.ExciseTaxValue, 0)), + IncomeWithholdValue: context.incomeWithholdValue, + TaxValue: round2(ItemList.reduce((sum, item) => sum + item.TaxAmount, 0)), + TotalValue: round2(ItemList.reduce((sum, item) => sum + item.TotalLineAmount, 0)), + TransactionWithholdValue: context.transactionWithholdValue, + InvoiceCurrency: invoice.currency, + }; + if (context.exchangeRate != null) ValueDetails.ExchangeRate = context.exchangeRate; + + return { + BuyerDetails: { + City: context.buyerCity ?? null, + Email: company.email ?? null, + HouseNumber: company.houseNo ?? null, + IdNumber: context.buyerIdNumber ?? null, + IdType: context.buyerIdType ?? null, + Tin: company.tin, + LegalName: company.name, + Phone: company.phone ?? null, + Region: company.region ?? null, + Country: context.buyerCountryCode ?? null, + Zone: company.zone ?? null, + Kebele: company.kebele ?? null, + VatNumber: company.vatNumber ?? null, + Wereda: company.woreda ?? null, + }, + DocumentDetails: { + DocumentNumber: context.documentNumber, + Date: (context.formatDate ?? formatEimsDate)(issuedAt), + Type: EIMS_DOCUMENT_TYPE, + }, + ItemList, + PaymentDetails: { Mode: context.payment.mode, PaymentTerm: context.payment.term }, + ReferenceDetails: { + PreviousIrn: context.previousIrn, + RelatedDocument: context.relatedDocument ?? null, + }, + SellerDetails: seller, + SourceSystem: { + CashierName: context.cashierName, + InvoiceCounter: context.invoiceCounter, + SalesPersonName: context.salesPersonName, + SystemNumber: context.systemNumber, + SystemType: context.systemType, + }, + TransactionType: context.transactionType, + ValueDetails, + Version: EIMS_VERSION, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts new file mode 100644 index 000000000..1ff0d0463 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -0,0 +1,190 @@ +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsSignerService } from "./eims-signer.service"; + +const CLIENT_SECRET = "super-secret-value"; +const API_KEY = "super-secret-apikey"; + +const cfg = (over: Partial = {}): EimsConfig => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: CLIENT_SECRET, + apiKey: API_KEY, + tin: "0000034558", + systemNumber: "B0360154BA", + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + ...over, +}); + +const loginBody = (accessToken: string, expiresIn = 3600) => ({ + data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn }, + status: "SUCCESS", +}); + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = cfg()) => + new EimsAuthService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + signer, + ); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +describe("EimsAuthService.getValidAccessToken", () => { + it("posts the signed login envelope to /auth/login with no Authorization header", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + + await build(post).getValidAccessToken(); + + expect(post).toHaveBeenCalledTimes(1); + const [url, body, options] = post.mock.calls[0]; + expect(url).toBe("https://core.mor.gov.et/auth/login"); + expect(options.headers).toEqual({ "Content-Type": "application/json" }); + expect(options.headers.Authorization).toBeUndefined(); + + expect(typeof body).toBe("string"); + expect(JSON.parse(body)).toEqual({ + request: { clientId: "cid", clientSecret: CLIENT_SECRET, apikey: API_KEY, tin: "0000034558" }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("returns the access token from data.accessToken", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + await expect(build(post).getValidAccessToken()).resolves.toBe("token-1"); + }); + + it("reuses a cached token instead of logging in again", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const auth = build(post); + + await auth.getValidAccessToken(); + await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("re-authenticates a skew-window before the token actually expires", async () => { + const post = jest + .fn() + .mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s + .mockReturnValueOnce(of({ data: loginBody("token-2") })); + const auth = build(post); + const start = Date.now(); + const clock = jest.spyOn(Date, "now"); + + try { + clock.mockReturnValue(start); + await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + + clock.mockReturnValue(start + 50_000); // inside the window: still cached + await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + expect(post).toHaveBeenCalledTimes(1); + + clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry + await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + expect(post).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }); + + it("logs in again after invalidate()", async () => { + const post = jest + .fn() + .mockReturnValueOnce(of({ data: loginBody("token-1") })) + .mockReturnValueOnce(of({ data: loginBody("token-2") })); + const auth = build(post); + + await auth.getValidAccessToken(); + auth.invalidate(); + await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + expect(post).toHaveBeenCalledTimes(2); + }); + + it("performs exactly one login for many concurrent callers", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const auth = build(post); + + const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken())); + + expect(post).toHaveBeenCalledTimes(1); + expect(new Set(tokens)).toEqual(new Set(["token-1"])); + }); + + it("refuses to call the gateway when EIMS is disabled", async () => { + const post = jest.fn(); + await expect(build(post, cfg({ enabled: false })).getValidAccessToken()).rejects.toThrow( + /EIMS integration is disabled/, + ); + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects a 200 response that carries no access token", async () => { + const post = jest.fn().mockReturnValue(of({ data: { data: {}, status: "SUCCESS" } })); + await expect(build(post).getValidAccessToken()).rejects.toThrow(/returned no accessToken/); + }); + + it("surfaces gateway errors without leaking credentials or the envelope", async () => { + const post = jest.fn().mockReturnValue( + throwError(() => + axiosErr(401, { + message: "GATEWAY ERROR", + statusCode: 401, + code: "4400", + details: [{ errorMessage: "Invalid Credentials" }], + // Fields the gateway must never echo back into our logs or exceptions: + signature: "SIGNATURE", + certificate: "CERTIFICATE", + accessToken: "leaked-token", + }), + ), + ); + + const error = (await build(post) + .getValidAccessToken() + .catch((e: Error) => e)) as Error & { response?: unknown }; + const serialized = JSON.stringify({ message: error.message, response: error.response }); + + expect(error.message).toContain("EIMS login failed (401)"); + expect(error.message).toContain("Invalid Credentials"); + for (const secret of [CLIENT_SECRET, API_KEY, "SIGNATURE", "CERTIFICATE", "leaked-token"]) { + expect(serialized).not.toContain(secret); + } + }); + + it("maps a timeout to a TIMEOUT failure without a status", async () => { + const timeout = new AxiosError("timeout of 30000ms exceeded", "ECONNABORTED"); + const post = jest.fn().mockReturnValue(throwError(() => timeout)); + + await expect(build(post).getValidAccessToken()).rejects.toThrow(/EIMS login timed out/); + }); + + it("maps an unreachable gateway to a NETWORK failure", async () => { + const refused = new AxiosError("connect ECONNREFUSED", "ECONNREFUSED"); + const post = jest.fn().mockReturnValue(throwError(() => refused)); + + await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts new file mode 100644 index 000000000..99af70c57 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -0,0 +1,116 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; +import { EimsApiException, EimsConfigException, toEimsApiException } from "./eims.errors"; +import { EimsLoginRequest, EimsLoginResponse } from "./eims.types"; + +interface TokenCache { + accessToken: string; + /** Epoch ms, already reduced by the configured skew. */ + expiresAt: number; +} + +/** Used when the gateway omits `expiresIn`; the observed value is 3600. */ +const FALLBACK_EXPIRES_IN_SECONDS = 3600; + +/** + * EIMS authentication: signed `POST /auth/login`, plus an in-memory access-token cache. + * + * Login is the one EIMS call that carries no bearer token, which is why it lives here rather than + * in the generic client. Tokens are held in memory only — never persisted, never logged, never + * returned to a frontend. + */ +@Injectable() +export class EimsAuthService { + private readonly logger = new Logger(EimsAuthService.name); + private cache: TokenCache | null = null; + private loginInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * A non-expired access token, logging in if needed. Concurrent callers share one login: the + * first caller stores the in-flight promise and everyone else awaits it. + */ + async getValidAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + if (this.loginInFlight) return this.loginInFlight; + + this.loginInFlight = this.login(); + try { + return await this.loginInFlight; + } finally { + this.loginInFlight = null; + } + } + + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ + invalidate(): void { + this.cache = null; + } + + private async login(): Promise { + const cfg = this.cfg; + if (!cfg.enabled) { + throw new EimsConfigException("EIMS integration is disabled; set EIMS_ENABLED=true to use it"); + } + + const request: EimsLoginRequest = { + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + apikey: cfg.apiKey, + tin: cfg.tin, + }; + const body = toSignedBody(this.signer.signRequest(request)); + + let response: EimsLoginResponse; + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}/auth/login`, body, { + headers: { "Content-Type": "application/json" }, + timeout: cfg.httpTimeoutMs, + }), + ); + response = res.data; + } catch (err) { + const mapped = toEimsApiException(err, "login"); + this.logger.error(mapped.message); + throw mapped; + } + + const accessToken = response?.data?.accessToken; + if (!accessToken) { + throw new EimsApiException("UNKNOWN", "EIMS login returned no accessToken"); + } + + const expiresIn = + Number.isFinite(response.data.expiresIn) && response.data.expiresIn > 0 + ? response.data.expiresIn + : FALLBACK_EXPIRES_IN_SECONDS; + + // TODO: implement `POST /auth/refresh-token` and hold `response.data.refreshToken`. The + // collection shows a bare `{refreshToken}` body with no envelope, but it also carries unsigned + // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. + // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, + // so that is one extra call an hour. + this.cache = { + accessToken, + expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + }; + this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`); + return accessToken; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts new file mode 100644 index 000000000..04418cf52 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -0,0 +1,67 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; +import { toEimsApiException } from "./eims.errors"; + +/** + * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * + * Login is not routed through here: `/auth/login` carries no bearer token and lives in + * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + */ +@Injectable() +export class EimsClientService { + private readonly logger = new Logger(EimsClientService.name); + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + private readonly auth: EimsAuthService, + private readonly signer: EimsSignerService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * A 401 invalidates the cached token and retries exactly once. + */ + async postSigned(path: string, request: TRequest): Promise { + return this.send(path, request, false); + } + + private async send( + path: string, + request: TRequest, + isRetry: boolean, + ): Promise { + const cfg = this.cfg; + const token = await this.auth.getValidAccessToken(); + const body = toSignedBody(this.signer.signRequest(request)); + + try { + const res = await firstValueFrom( + this.http.post(`${cfg.baseUrl}${path}`, body, { + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + timeout: cfg.httpTimeoutMs, + }), + ); + return res.data; + } catch (err) { + const mapped = toEimsApiException(err, `POST ${path}`); + if (mapped.kind === "AUTH" && !isRetry) { + this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); + this.auth.invalidate(); + return this.send(path, request, true); + } + this.logger.error(mapped.message); + throw mapped; + } + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts new file mode 100644 index 000000000..b68a4a32f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { KeyObject, createPrivateKey } from "node:crypto"; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { EimsConfig } from "../../config/eims.config"; +import { EimsConfigException } from "./eims.errors"; + +/** + * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. + * + * The certificate is sent as base64 of the **exact bytes of the issued file** — it is deliberately + * never parsed, re-encoded or re-exported, because that is what produced a working live login. + * The private key never leaves this process: it is only ever used to produce a signature. + */ +@Injectable() +export class EimsCredentialsProvider { + private readonly logger = new Logger(EimsCredentialsProvider.name); + private privateKey: KeyObject | null = null; + private certificateBase64: string | null = null; + + constructor(private readonly config: ConfigService) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + getPrivateKey(): KeyObject { + if (this.privateKey) return this.privateKey; + + const path = this.cfg.privateKeyPath; + if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + + let key: KeyObject; + try { + key = createPrivateKey(readFileSync(path)); + } catch (err) { + // The path is operational information, not a secret; the key material never appears. + throw new EimsConfigException( + `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + ); + } + if (key.asymmetricKeyType !== "rsa") { + throw new EimsConfigException( + `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + ); + } + + this.privateKey = key; + this.logger.log(`EIMS private key loaded (RSA-${key.asymmetricKeyDetails?.modulusLength ?? "?"})`); + return key; + } + + /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + getCertificateBase64(): string { + if (this.certificateBase64) return this.certificateBase64; + + const path = this.cfg.certificatePath; + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); + + let bytes: Buffer; + try { + bytes = readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS certificate at ${path} could not be read: ${(err as Error).message}`, + ); + } + if (bytes.length === 0) { + throw new EimsConfigException(`EIMS certificate at ${path} is empty`); + } + + this.certificateBase64 = bytes.toString("base64"); + this.logger.log(`EIMS certificate bundle loaded (${bytes.length} bytes)`); + return this.certificateBase64; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts new file mode 100644 index 000000000..5e408ddf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -0,0 +1,118 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createVerify, generateKeyPairSync } from "node:crypto"; +import { ConfigService } from "@nestjs/config"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService, toSignedBody } from "./eims-signer.service"; + +/** + * Test-only key material: generated per run, never a production key. The "certificate" fixture is + * an arbitrary byte blob — the point is that its exact bytes survive base64 round-tripping, not + * that it is a valid X.509 chain. + */ +const CERTIFICATE_FIXTURE = "Subject: CN=TEST\n-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n"; + +let dir: string; +let keyPath: string; +let certPath: string; +let publicKeyPem: string; +let signer: EimsSignerService; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "eims-signer-")); + keyPath = join(dir, "private_key.key"); + certPath = join(dir, "certificate.pem.txt"); + + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + writeFileSync(keyPath, privateKey.export({ type: "pkcs8", format: "pem" })); + writeFileSync(certPath, CERTIFICATE_FIXTURE, "utf8"); + publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + + const config = { + get: () => ({ privateKeyPath: keyPath, certificatePath: certPath }), + } as unknown as ConfigService; + signer = new EimsSignerService(new EimsCredentialsProvider(config)); +}); + +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const login = () => ({ clientId: "cid", clientSecret: "secret", apikey: "key", tin: "0000000000" }); + +const verify = (payload: string, signature: string): boolean => + createVerify("RSA-SHA512").update(payload, "utf8").verify(publicKeyPem, signature, "base64"); + +describe("EimsSignerService", () => { + it("produces a signature that verifies against the matching public key", () => { + const signed = signer.signRequest(login()); + expect(verify(JSON.stringify(signed.request), signed.signature)).toBe(true); + }); + + it("fails verification when a single request field changes", () => { + const signed = signer.signRequest(login()); + const tampered = JSON.stringify({ ...signed.request, tin: "9999999999" }); + expect(verify(tampered, signed.signature)).toBe(false); + }); + + it("emits a 256-byte signature for an RSA-2048 key", () => { + const signed = signer.signRequest(login()); + expect(Buffer.from(signed.signature, "base64")).toHaveLength(256); + }); + + it("sends the certificate as base64 of the file's exact bytes", () => { + const signed = signer.signRequest(login()); + expect(signed.certificate).toBe(readFileSync(certPath).toString("base64")); + expect(Buffer.from(signed.certificate, "base64").equals(readFileSync(certPath))).toBe(true); + }); + + it("signs the inner request only, and the wire body carries those exact bytes", () => { + const signed = signer.signRequest(login()); + const body = toSignedBody(signed); + + // The signed string appears verbatim inside the transmitted envelope. + expect(body).toContain(`"request":${JSON.stringify(signed.request)}`); + // Compact, never pretty-printed. + expect(body).not.toMatch(/\n/); + expect(JSON.parse(body)).toEqual({ + request: login(), + signature: signed.signature, + certificate: signed.certificate, + }); + }); + + it("does not mutate the request object", () => { + const request = login(); + const signed = signer.signRequest(request); + expect(signed.request).toBe(request); + expect(request).toEqual(login()); + }); + + it("reuses the loaded key and certificate across calls", () => { + const first = signer.signRequest(login()); + const second = signer.signRequest(login()); + // PKCS#1 v1.5 is deterministic: same key + same payload ⇒ identical signature. + expect(second.signature).toBe(first.signature); + expect(second.certificate).toBe(first.certificate); + }); +}); + +describe("EimsCredentialsProvider", () => { + const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => + new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + + it("fails clearly when the key path is unset", () => { + expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); + }); + + it("fails clearly when the key file is missing", () => { + expect(() => providerFor({ privateKeyPath: join(dir, "nope.key") }).getPrivateKey()).toThrow( + /could not be read or parsed/, + ); + }); + + it("fails clearly when the certificate file is empty", () => { + const emptyPath = join(dir, "empty.txt"); + writeFileSync(emptyPath, ""); + expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); + }); +}); 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 new file mode 100644 index 000000000..babec6b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts @@ -0,0 +1,37 @@ +import { createSign } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignedRequest } from "./eims.types"; + +/** + * Signs EIMS request objects, reproducing the process that produced a working live access token: + * + * 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), + * 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key), + * 5. base64 of the certificate file's exact bytes. + * + * The outer `{request, signature, certificate}` envelope is never itself signed, and the request + * object is never mutated after serialization. + */ +@Injectable() +export class EimsSignerService { + constructor(private readonly credentials: EimsCredentialsProvider) {} + + signRequest(request: T): EimsSignedRequest { + const payload = JSON.stringify(request); + const signature = createSign("RSA-SHA512") + .update(payload, "utf8") + .sign(this.credentials.getPrivateKey(), "base64"); + + return { request, signature, certificate: this.credentials.getCertificateBase64() }; + } +} + +/** + * Exact wire body for a signed envelope. Serializing here (rather than handing axios an object) + * keeps one serializer in play: the `request` segment of this string is byte-identical to the + * string that was signed. + */ +export const toSignedBody = (signed: EimsSignedRequest): string => JSON.stringify(signed); diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts new file mode 100644 index 000000000..3be21fdd3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -0,0 +1,89 @@ +import { BadGatewayException, ServiceUnavailableException } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { EimsErrorResponse } from "./eims.types"; + +export type EimsFailureKind = + | "NETWORK" + | "TIMEOUT" + | "SCHEMA_VALIDATION" + | "AUTH" + | "FORBIDDEN" + | "RULE_VALIDATION" + | "SERVER" + | "UNKNOWN"; + +/** Raised when EIMS is disabled or its credential files are unusable. */ +export class EimsConfigException extends ServiceUnavailableException { + constructor(message: string) { + super({ code: "EIMS_NOT_CONFIGURED", message }); + } +} + +/** + * A failed EIMS call. Carries only the gateway's own error reporting — never the request body, + * signature, certificate, bearer token or any configured secret. + */ +export class EimsApiException extends BadGatewayException { + constructor( + readonly kind: EimsFailureKind, + message: string, + readonly httpStatus?: number, + readonly details?: EimsErrorResponse, + ) { + super({ code: `EIMS_${kind}`, message }); + } +} + +const SAFE_KEYS = ["message", "statusCode", "code", "details", "body"] as const; + +/** + * Keep only the gateway's error-reporting fields. Anything else a response might carry — an echoed + * request, a token, a signature — is dropped before it can reach a log or an exception payload. + */ +export function redactEimsBody(data: unknown): EimsErrorResponse | undefined { + if (!data || typeof data !== "object") return undefined; + const source = data as Record; + const safe: Record = {}; + for (const key of SAFE_KEYS) { + if (source[key] !== undefined) safe[key] = source[key]; + } + return Object.keys(safe).length > 0 ? (safe as EimsErrorResponse) : undefined; +} + +const kindFor = (status: number): EimsFailureKind => { + if (status === 400) return "SCHEMA_VALIDATION"; + if (status === 401) return "AUTH"; + if (status === 403) return "FORBIDDEN"; + if (status === 406) return "RULE_VALIDATION"; + if (status >= 500) return "SERVER"; + return "UNKNOWN"; +}; + +/** First error line the gateway gives us, whichever shape it used. */ +const describe = (body: EimsErrorResponse | undefined): string => { + if (!body) return "no error body"; + const detail = body.details?.find((d) => d.errorMessage)?.errorMessage; + return [body.message, body.code && `code=${body.code}`, detail].filter(Boolean).join(" ") || "no error body"; +}; + +/** + * Normalise anything thrown by an EIMS HTTP call into an `EimsApiException`. `operation` is a + * short label such as `"login"` or `"POST /v1/register"` — never a payload. + */ +export function toEimsApiException(err: unknown, operation: string): EimsApiException { + if (err instanceof EimsApiException) return err; + + if (err instanceof AxiosError) { + if (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT") { + return new EimsApiException("TIMEOUT", `EIMS ${operation} timed out`); + } + if (!err.response) { + return new EimsApiException("NETWORK", `EIMS ${operation} could not reach the gateway (${err.code ?? "no code"})`); + } + const status = err.response.status; + const body = redactEimsBody(err.response.data); + return new EimsApiException(kindFor(status), `EIMS ${operation} failed (${status}): ${describe(body)}`, status, body); + } + + return new EimsApiException("UNKNOWN", `EIMS ${operation} failed: ${(err as Error)?.message ?? "unknown error"}`); +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts new file mode 100644 index 000000000..4c953c14e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -0,0 +1,19 @@ +import { HttpModule } from "@nestjs/axios"; +import { Module } from "@nestjs/common"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsSignerService } from "./eims-signer.service"; + +/** + * MoR EIMS e-invoicing transport. Exports only what other modules will consume; the credential + * loader and signer stay internal so the private key has exactly one user. + */ +@Module({ + imports: [ + HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), + ], + providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService], + exports: [EimsAuthService, EimsClientService], +}) +export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts new file mode 100644 index 000000000..6e74604bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -0,0 +1,46 @@ +/** + * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. + * + * Every protected payload is the same envelope: the business object under `request`, a base64 + * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. + */ +export interface EimsSignedRequest { + request: T; + signature: string; + certificate: string; +} + +/** Inner request of `POST /auth/login`. Note the lowercase `apikey` — that is the wire name. */ +export interface EimsLoginRequest { + clientId: string; + clientSecret: string; + apikey: string; + tin: string; +} + +export interface EimsLoginData { + accessToken: string; + refreshToken: string; + /** Observed as a UUID on login and `null` on refresh; unused today. */ + encryptionKey: string | null; + /** Seconds. Observed value: 3600. */ + expiresIn: number; +} + +export interface EimsLoginResponse { + data: EimsLoginData; + status: string; +} + +/** + * Error bodies differ per failure mode: gateway errors carry `message`/`code`/`details`, + * schema errors carry a JSON-Schema violation array under `body`, rule errors carry + * `[{portion, errorMessage[]}]` under `body`. Only these fields are ever surfaced or logged. + */ +export interface EimsErrorResponse { + message?: string; + statusCode?: number; + code?: string; + details?: { errorMessage?: string; field?: string }[]; + body?: unknown; +} diff --git a/apps/edr-freight-api/src/scripts/eims-login.ts b/apps/edr-freight-api/src/scripts/eims-login.ts new file mode 100644 index 000000000..f9cc60d27 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/eims-login.ts @@ -0,0 +1,40 @@ +import "dotenv/config"; +import axios from "axios"; +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import eimsConfig, { EimsConfig } from "../config/eims.config"; +import { EimsAuthService } from "../modules/eims/eims-auth.service"; +import { EimsCredentialsProvider } from "../modules/eims/eims-credentials.provider"; +import { EimsSignerService } from "../modules/eims/eims-signer.service"; + +/** + * Manual, developer-run live check of EIMS authentication. + * + * Run explicitly: pnpm --filter @edr/freight-api eims:login + * + * Reads credentials from the local .env only. Never runs at boot, never runs in the test suite, + * and prints no token, secret, signature or certificate — only whether login succeeded. + */ +async function main(): Promise { + const config = eimsConfig() as EimsConfig; + if (!config.enabled) { + throw new Error("EIMS_ENABLED is not true — set it in .env before running this check"); + } + + const configService = { get: () => config } as unknown as ConfigService; + const http = new HttpService(axios.create()); + const credentials = new EimsCredentialsProvider(configService); + const auth = new EimsAuthService(http, configService, new EimsSignerService(credentials)); + + console.log(`POST ${config.baseUrl}/auth/login (tin=${config.tin})`); + const token = await auth.getValidAccessToken(); + console.log(`✔ login succeeded — access token received (${token.length} chars, not printed)`); + + const cached = await auth.getValidAccessToken(); + console.log(`✔ second call served from cache: ${cached === token}`); +} + +main().catch((err: Error) => { + console.error(`✘ ${err.message}`); + process.exitCode = 1; +}); From 75730190381474fb59039b0c6975e486c6329fb8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:22:46 +0000 Subject: [PATCH 2/7] feat(eims): register invoices with MoR EIMS and persist the outcome Add manual single-invoice registration, verification and reconciliation. Nothing submits automatically; invoice creation is untouched. Sequencing uses a durable reservation. The counter is consumed and the holder recorded in a committed transaction before the request leaves the process, and the HTTP call runs outside every transaction. A counter is therefore never reused once an attempt begins, a crash mid-flight leaves the reservation standing instead of inviting a blind resubmission, and an ambiguous result blocks the whole system number rather than one invoice -- PreviousIrn is unknown, so any later document would chain to a stale IRN. Deterministic rejections (400/406/401/403) mark the invoice FAILED and clear the block. Timeouts and 5xx mark it UNKNOWN and keep it. Since /v1/verify takes an IRN we never received in that case, POST :id/eims/resolve is the exit: record the IRN confirmed in the MoR portal, or discard. A recorded IRN is verified against the gateway first and refused unless EIMS reports it against this invoice's document number. Business and tax configuration is validated locally before anything is locked, allocated or sent, so a missing tax code fails naming the exact environment variables instead of at the gateway. No tax value is defaulted. Filing gets its own permission (invoices:eims_register) rather than riding on invoices:export -- registration is irreversible at MoR and must not follow from the right to download a PDF. Co-Authored-By: Claude Opus 5 --- .../edr-freight-api/src/config/eims.config.ts | 77 +++ .../3300000000000-EimsInvoiceRegistration.ts | 73 +++ .../billing/entities/invoice.entity.ts | 24 + .../eims/dto/resolve-eims-registration.dto.ts | 25 + .../modules/eims/eims-auth.service.spec.ts | 31 +- .../src/modules/eims/eims-client.service.ts | 19 +- .../src/modules/eims/eims-invoice-context.ts | 114 ++++ .../eims-invoice-registration.service.spec.ts | 507 ++++++++++++++++++ .../eims/eims-invoice-registration.service.ts | 472 ++++++++++++++++ .../modules/eims/eims-invoice.controller.ts | 60 +++ .../modules/eims/eims-registration.types.ts | 87 +++ .../src/modules/eims/eims.module.ts | 24 +- .../eims/entities/eims-system-state.entity.ts | 42 ++ .../src/seed/freight-permissions.registry.ts | 8 + 14 files changed, 1555 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-registration.types.ts create mode 100644 apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 37cbbc84b..4e8684a5b 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -26,6 +26,44 @@ export interface EimsConfig { httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; + /** + * Seller identity and tax/business treatment for the invoice document. + * + * None of this is derivable from the database: EDR's own legal identity exists nowhere in the + * codebase, and the app models no tax at all. Values are required at registration time and are + * validated there rather than at boot, so a deployment can run with EIMS enabled for + * authentication before finance has signed off on the tax treatment. + */ + invoice: EimsInvoiceConfig; +} + +export interface EimsInvoiceConfig { + sellerLegalName: string; + sellerVatNumber: string; + sellerPhone: string; + sellerEmail: string; + /** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */ + sellerRegion: string; + sellerWereda: string; + sellerCity: string | null; + sellerSubCity: string | null; + sellerHouseNumber: string | null; + sellerLocality: string | null; + /** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */ + taxCode: string; + taxRatePercent: number | null; + exciseTaxValue: number | null; + incomeWithholdValue: number | null; + transactionWithholdValue: number | null; + /** B2B / B2C — a tax classification, so it is configured, not inferred. */ + transactionType: string; + natureOfSupplies: string; + paymentMode: string; + paymentTerm: string; + unitDefault: string; + buyerCountryCode: string | null; + cashierName: string | null; + salesPersonName: string | null; } const REQUIRED_VARS = [ @@ -46,6 +84,14 @@ const positiveInt = (raw: string | undefined, fallback: number, name: string): n return value; }; +/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ +const optionalNumber = (raw: string | undefined, name: string): number | null => { + if (raw === undefined || raw === "") return null; + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${name} must be a number`); + return value; +}; + export default registerAs("eims", (): EimsConfig => { const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true"; const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, ""); @@ -66,6 +112,37 @@ export default registerAs("eims", (): EimsConfig => { certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", httpTimeoutMs, tokenSkewMs, + invoice: { + sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", + sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", + sellerPhone: process.env.EIMS_SELLER_PHONE ?? "", + sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "", + sellerRegion: process.env.EIMS_SELLER_REGION ?? "", + sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "", + sellerCity: process.env.EIMS_SELLER_CITY || null, + sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null, + sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null, + sellerLocality: process.env.EIMS_SELLER_LOCALITY || null, + taxCode: process.env.EIMS_TAX_CODE ?? "", + taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"), + exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"), + incomeWithholdValue: optionalNumber( + process.env.EIMS_INCOME_WITHHOLD_VALUE, + "EIMS_INCOME_WITHHOLD_VALUE", + ), + transactionWithholdValue: optionalNumber( + process.env.EIMS_TRANSACTION_WITHHOLD_VALUE, + "EIMS_TRANSACTION_WITHHOLD_VALUE", + ), + transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "", + natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "", + paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", + paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", + unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", + buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, + cashierName: process.env.EIMS_CASHIER_NAME || null, + salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + }, }; if (!enabled) return base; diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts new file mode 100644 index 000000000..c80dfcd1e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * EIMS registration state. + * + * `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice + * consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the + * database-level guarantee that one IRN can never be recorded against two invoices, independent of + * application logic. + * + * `freight.eims_system_state` is a single row per MoR system number holding the sequence the + * gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn` + * of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter + * and the IRN chain stay consistent under concurrent submissions. + * + * The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and + * the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves + * evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set + * when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later + * document for this system number would chain to a stale `PreviousIrn` and registration stops until + * a human resolves it. + * + * `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string + * ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored + * verbatim so a compliance value is never mangled by a parse. + */ +export class EimsInvoiceRegistration3300000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + ADD COLUMN IF NOT EXISTS eims_irn varchar(64), + ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint, + ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_last_error jsonb + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn + ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_system_state ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + system_number varchar(32) NOT NULL UNIQUE, + next_invoice_counter bigint NOT NULL DEFAULT 1, + previous_irn varchar(64), + in_flight_invoice_id uuid, + in_flight_counter bigint, + blocked_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_status, + DROP COLUMN IF EXISTS eims_irn, + DROP COLUMN IF EXISTS eims_invoice_counter, + DROP COLUMN IF EXISTS eims_submitted_at, + DROP COLUMN IF EXISTS eims_ack_date, + DROP COLUMN IF EXISTS eims_last_error + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 23c332f80..c5c000943 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types"; import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; @@ -105,4 +106,27 @@ export class Invoice extends BaseEntity { @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; + + /** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */ + @Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" }) + eimsStatus!: EimsInvoiceStatus; + + /** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */ + @Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true }) + eimsIrn?: string | null; + + /** The `SourceSystem.InvoiceCounter` this invoice consumed. */ + @Column({ name: "eims_invoice_counter", type: "bigint", nullable: true }) + eimsInvoiceCounter?: number | null; + + @Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true }) + eimsSubmittedAt?: Date | null; + + /** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */ + @Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true }) + eimsAckDate?: string | null; + + /** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */ + @Column({ name: "eims_last_error", type: "jsonb", nullable: true }) + eimsLastError?: EimsInvoiceError | null; } diff --git a/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts new file mode 100644 index 000000000..66896fd18 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/resolve-eims-registration.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, Length } from "class-validator"; + +/** + * Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is + * meaningful: supply the IRN confirmed with MoR, or discard the attempt. + */ +export class ResolveEimsRegistrationDto { + @ApiPropertyOptional({ + description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.", + example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", + }) + @IsOptional() + @IsString() + @Length(1, 64) + irn?: string; + + @ApiPropertyOptional({ + description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.", + example: true, + }) + @IsOptional() + @IsBoolean() + discard?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 1ff0d0463..75a702e94 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,7 +2,7 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; -import { EimsConfig } from "../../config/eims.config"; +import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; import { EimsAuthService } from "./eims-auth.service"; import { EimsSignerService } from "./eims-signer.service"; @@ -22,6 +22,35 @@ const cfg = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ +export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ + sellerLegalName: "Ethio-Djibouti Railway S.C.", + sellerVatNumber: "0000000000", + sellerPhone: "0911223344", + sellerEmail: "finance@example.et", + sellerRegion: "13", + sellerWereda: "574", + sellerCity: null, + sellerSubCity: null, + sellerHouseNumber: null, + sellerLocality: null, + taxCode: "VAT15", + taxRatePercent: 15, + exciseTaxValue: 0, + incomeWithholdValue: 0, + transactionWithholdValue: 0, + transactionType: "B2B", + natureOfSupplies: "Service", + paymentMode: "CASH", + paymentTerm: "IMMIDIATE", + unitDefault: "PCS", + buyerCountryCode: null, + cashierName: null, + salesPersonName: null, ...over, }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 04418cf52..610647b1a 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -33,17 +33,30 @@ export class EimsClientService { * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { - return this.send(path, request, false); + return this.send(path, request, false, true); + } + + /** + * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. + * + * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a + * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point + * so that if the live gateway turns out to require signing after all, exactly one call site + * changes — `postSigned` is already the alternative. + */ + async postBearer(path: string, request: TRequest): Promise { + return this.send(path, request, false, false); } private async send( path: string, request: TRequest, isRetry: boolean, + signed: boolean, ): Promise { const cfg = this.cfg; const token = await this.auth.getValidAccessToken(); - const body = toSignedBody(this.signer.signRequest(request)); + const body = signed ? toSignedBody(this.signer.signRequest(request)) : request; try { const res = await firstValueFrom( @@ -58,7 +71,7 @@ export class EimsClientService { if (mapped.kind === "AUTH" && !isRetry) { this.logger.warn(`EIMS rejected the token on ${path}; re-authenticating once`); this.auth.invalidate(); - return this.send(path, request, true); + return this.send(path, request, true, signed); } this.logger.error(mapped.message); throw mapped; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts new file mode 100644 index 000000000..1a68f2ce4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -0,0 +1,114 @@ +import { BadRequestException } from "@nestjs/common"; +import { EimsConfig } from "../../config/eims.config"; +import { + EimsMapperContext, + EimsMapperLine, + EimsSellerDetails, +} from "../billing/eims-invoice.mapper"; + +/** + * Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires. + * + * Everything here is unavailable from the database by construction: EDR's own legal identity is not + * modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0, + * `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it, + * a missing value fails **here** — locally, before a single byte reaches the gateway — naming the + * exact environment variables to set. + */ + +interface RequiredSpec { + env: string; + value: string | number | null | undefined; +} + +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ + { env: "EIMS_TIN", value: tin }, + { env: "EIMS_SYSTEM_NUMBER", value: systemNumber }, + { env: "EIMS_SYSTEM_TYPE", value: systemType }, + { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, + { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, + { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, + { env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail }, + { env: "EIMS_SELLER_REGION", value: invoice.sellerRegion }, + { env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda }, + { env: "EIMS_TAX_CODE", value: invoice.taxCode }, + { env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent }, + { env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue }, + { env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue }, + { env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType }, + { env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies }, + { env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode }, + { env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm }, + { env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault }, +]; + +/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */ +export function assertEimsInvoiceConfig(config: EimsConfig): void { + const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType) + .filter(({ value }) => value === null || value === undefined || value === "") + .map(({ env }) => env); + + if (missing.length > 0) { + throw new BadRequestException({ + code: "EIMS_INVOICE_CONFIG_INCOMPLETE", + message: + "EIMS invoice registration is not configured. Set these environment variables " + + `(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`, + }); + } +} + +export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { + const { invoice } = config; + return { + City: invoice.sellerCity, + Email: invoice.sellerEmail, + HouseNumber: invoice.sellerHouseNumber, + LegalName: invoice.sellerLegalName, + Locality: invoice.sellerLocality, + Phone: invoice.sellerPhone, + Region: invoice.sellerRegion, + SubCity: invoice.sellerSubCity, + Tin: config.tin, + VatNumber: invoice.sellerVatNumber, + Wereda: invoice.sellerWereda, + }; +} + +export interface EimsContextInput { + /** `DocumentDetails.DocumentNumber`. The caller decides its source. */ + documentNumber: string; + invoiceCounter: number; + previousIrn: string | null; + /** Required when the invoice currency is not ETB. */ + exchangeRate?: number | null; +} + +export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { + const { invoice } = config; + // Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call. + const taxCode = invoice.taxCode; + const ratePercent = invoice.taxRatePercent!; + const exciseTaxValue = invoice.exciseTaxValue ?? 0; + + return { + systemNumber: config.systemNumber, + systemType: config.systemType, + documentNumber: input.documentNumber, + invoiceCounter: input.invoiceCounter, + previousIrn: input.previousIrn, + cashierName: invoice.cashierName, + salesPersonName: invoice.salesPersonName, + transactionType: invoice.transactionType, + payment: { mode: invoice.paymentMode, term: invoice.paymentTerm }, + // One treatment for every line today. The mapper resolves tax per line, so a future + // charge-type-specific rule slots in here without touching the mapper. + taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }), + natureOfSupplies: invoice.natureOfSupplies, + unitDefault: invoice.unitDefault, + incomeWithholdValue: invoice.incomeWithholdValue!, + transactionWithholdValue: invoice.transactionWithholdValue!, + buyerCountryCode: invoice.buyerCountryCode, + exchangeRate: input.exchangeRate ?? null, + }; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts new file mode 100644 index 000000000..362c754cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -0,0 +1,507 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; +import { eimsInvoiceConfig } from "./eims-auth.service.spec"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException } from "./eims.errors"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +const SYSTEM_NUMBER = "B0360154BA"; +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222"; +const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0"; + +const config = (over: Partial = {}): EimsConfig => + ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "secret", + apiKey: "key", + tin: "0000034558", + systemNumber: SYSTEM_NUMBER, + systemType: "SYS", + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(over), + }) as EimsConfig; + +const invoiceRow = (over: Partial = {}): Invoice => + ({ + id: INVOICE_ID, + invoiceNumber: "INV-20260807-00042", + currency: "ETB", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "10000.00", + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsIrn: null, + eimsInvoiceCounter: null, + eimsSubmittedAt: null, + eimsAckDate: null, + eimsLastError: null, + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + email: "buyer@abc.et", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + ...over, + }) as unknown as Invoice; + +const LINES = [ + { + chargeType: "RAIL_FREIGHT", + description: "Addis to Djibouti", + quantity: "1.00", + unitRate: "10000.00", + amount: "10000.00", + }, +]; + +/** + * In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()` + * returns the live object — enough to assert ordering, values and the reservation lifecycle without + * a database. + */ +class FakeDb { + invoices = new Map(); + state: EimsSystemState | null = null; + /** Runs before every transaction body, to simulate a concurrent writer. */ + onTransaction: (() => void) | null = null; + + constructor(invoices: Invoice[], state?: Partial) { + for (const inv of invoices) this.invoices.set(inv.id, inv); + this.state = { + id: "state-1", + systemNumber: SYSTEM_NUMBER, + nextInvoiceCounter: 7, + previousIrn: null, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + ...state, + } as EimsSystemState; + } + + private manager = { + createQueryBuilder: (entity: unknown) => { + const isInvoice = entity === Invoice; + let id: string | undefined; + const builder = { + setLock: () => builder, + where: (_clause: string, params: Record) => { + id = params.invoiceId ?? params.systemNumber; + return builder; + }, + getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state), + }; + return builder; + }, + findOne: async (_entity: unknown, options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + update: async (entity: unknown, id: string, patch: Record) => { + if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch); + else Object.assign(this.state!, patch); + }, + query: async () => [], + getRepository: () => ({ + findOne: async (options: { where: { id: string } }) => + this.invoices.get(options.where.id) ?? null, + }), + }; + + asDataSource(): DataSource { + return { + manager: this.manager, + getRepository: this.manager.getRepository, + query: async () => LINES, + transaction: async (body: (m: unknown) => Promise) => { + this.onTransaction?.(); + return body(this.manager); + }, + } as unknown as DataSource; + } +} + +const build = ( + db: FakeDb, + postSigned: jest.Mock, + cfg: EimsConfig = config(), + postBearer: jest.Mock = jest.fn(), +) => + new EimsInvoiceRegistrationService( + db.asDataSource(), + { get: () => cfg } as unknown as ConfigService, + { postSigned, postBearer } as unknown as EimsClientService, + ); + +/** Document number the fixtures register under; `/v1/verify` must echo it back. */ +const DOCUMENT_NUMBER = "INV-20260807-00042"; + +/** + * `/v1/verify` success. The response spells the reference `Irn` while the request uses `irn`, and + * the collection's own fixture uses a *different* example value on each side — so nothing here + * assumes the two match. + */ +const verifyResponse = (over: Record = {}) => ({ + statusCode: 200, + message: "SUCCESS", + body: { + Irn: IRN, + TransactionType: "B2B", + DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" }, + Version: "1", + ...over, + }, +}); + +const okResponse = (irn = IRN) => + ({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } }); + +const apiError = (kind: string, status?: number) => + new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status); + +describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { + it("registers, persists the IRN and advances the chain", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + expect(postSigned).toHaveBeenCalledTimes(1); + expect(postSigned.mock.calls[0][0]).toBe("/v1/register"); + expect(view).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: IRN, + eimsInvoiceCounter: 7, + eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]", + }); + expect(db.state).toMatchObject({ + previousIrn: IRN, + nextInvoiceCounter: 8, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + + it("sends the exact reserved counter and previous IRN to the mapper", async () => { + const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" }); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + + await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.InvoiceCounter).toBe(42); + expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN"); + expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042"); + expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); + }); + + it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const postSigned = jest.fn(); + + const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID); + + expect(postSigned).not.toHaveBeenCalled(); + expect(view.eimsIrn).toBe(IRN); + }); + + it("lets only one of two concurrent calls reach EIMS", async () => { + const db = new FakeDb([invoiceRow()]); + let resolvePost: (v: unknown) => void = () => {}; + const postSigned = jest + .fn() + .mockImplementation(() => new Promise((resolve) => (resolvePost = resolve))); + const service = build(db, postSigned); + + const first = service.registerInvoiceWithEims(INVOICE_ID); + // Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`. + await new Promise((resolve) => setImmediate(resolve)); + expect(postSigned).toHaveBeenCalledTimes(1); + + const second = service.registerInvoiceWithEims(INVOICE_ID); + + await expect(second).rejects.toBeInstanceOf(ConflictException); + resolvePost(okResponse()); + await first; + expect(postSigned).toHaveBeenCalledTimes(1); + }); + + it("blocks a different invoice while a submission is in flight (survives a restart)", async () => { + // A committed reservation left behind by a dead process. + const db = new FakeDb( + [ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }), + invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }), + ], + { inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 }, + ); + const postSigned = jest.fn(); + + await expect( + build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID), + ).rejects.toThrow(/already in flight/); + expect(postSigned).not.toHaveBeenCalled(); + }); + + it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + + await expect( + build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + }); + + it.each([ + ["SCHEMA_VALIDATION", 400], + ["RULE_VALIDATION", 406], + ])("marks %s (%i) FAILED and clears the global block", async (kind, status) => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockRejectedValue(apiError(kind, status)); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + nextInvoiceCounter: 8, // consumed: the attempt reached the gateway + }); + }); + + it("treats a success response with no IRN as a failed registration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /returned no IRN/, + ); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed); + expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null }); + }); + + it("marks a timeout UNKNOWN and keeps the system blocked", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID); + expect(db.state!.blockedReason).toMatch(/never acknowledged/); + expect(db.state!.previousIrn).toBeNull(); + }); + + it("an UNKNOWN result blocks a different invoice too", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT")); + const service = build(db, postSigned); + + await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow( + /registration is blocked/, + ); + expect(postSigned).toHaveBeenCalledTimes(1); + }); + + it("never reuses a counter once an attempt has begun", async () => { + const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]); + const postSigned = jest + .fn() + .mockRejectedValueOnce(apiError("RULE_VALIDATION", 406)) + .mockResolvedValueOnce(okResponse()); + const service = build(db, postSigned); + + await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsApiException, + ); + await service.registerInvoiceWithEims(OTHER_INVOICE_ID); + + expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7); + expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8); + }); +}); + +describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { + it("verifies the stored IRN over the unsigned bearer transport", async () => { + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postSigned = jest.fn(); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( + INVOICE_ID, + ); + + // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).not.toHaveBeenCalled(); + expect(result.body).toMatchObject({ Irn: IRN }); + }); + + it("accepts a response whose Irn differs from the one sent", async () => { + // The supplied collection's own fixture does exactly this; equality would assert a property + // of the mock, not of the gateway. + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).resolves.toMatchObject({ body: { Irn: "a-different-irn" } }); + }); + + it("rejects a 200 that carries no Irn", async () => { + const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/returned no Irn/); + }); + + it("refuses to verify an invoice with no IRN", async () => { + const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); + const postBearer = jest.fn(); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/no EIMS IRN to verify/); + expect(postBearer).not.toHaveBeenCalled(); + }); +}); + +describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { + const blocked = () => + new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], { + inFlightInvoiceId: INVOICE_ID, + inFlightCounter: 7, + nextInvoiceCounter: 8, + blockedReason: "never acknowledged", + }); + + it("records a confirmed IRN, resumes the chain and clears the block", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + INVOICE_ID, + { irn: IRN }, + ); + + // The IRN is confirmed at the gateway before it is ever written. + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); + expect(db.state).toMatchObject({ + previousIrn: IRN, + inFlightInvoiceId: null, + blockedReason: null, + }); + }); + + it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue( + verifyResponse({ + DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" }, + }), + ); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/not INV-20260807-00042/); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + + it("refuses an IRN the gateway does not acknowledge at all", async () => { + const db = blocked(); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/returned no Irn/); + expect(db.state!.blockedReason).toBe("never acknowledged"); + }); + + it("discards the attempt, leaving the chain where it was", async () => { + const db = blocked(); + const postBearer = jest.fn(); + + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + INVOICE_ID, + { discard: true }, + ); + + expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); + expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(db.state).toMatchObject({ + previousIrn: null, + inFlightInvoiceId: null, + blockedReason: null, + }); + }); + + it("refuses to resolve an invoice that is not the in-flight one", async () => { + const db = blocked(); + db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID })); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + irn: IRN, + }), + ).rejects.toThrow(/in-flight EIMS submission is invoice/); + }); + + it("requires either an IRN or an explicit discard", async () => { + await expect( + build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts new file mode 100644 index 000000000..44b92cd9b --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -0,0 +1,472 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager } from "typeorm"; +import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; + +import { EimsConfig } from "../../config/eims.config"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { + EimsInvoiceRequest, + EimsMapperLine, + toEimsInvoice, +} from "../billing/eims-invoice.mapper"; +import { EimsClientService } from "./eims-client.service"; +import { EimsApiException } from "./eims.errors"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { + assertEimsInvoiceConfig, + buildEimsContext, + buildEimsSeller, +} from "./eims-invoice-context"; +import { + EimsInvoiceError, + EimsInvoiceStatus, + EimsInvoiceStatusView, + EimsRegisterResponse, + EimsVerifyRequest, + EimsVerifyResponse, +} from "./eims-registration.types"; + +/** + * Failure kinds where the gateway gave a complete answer: the document was rejected and is + * definitively not registered. These clear the system-wide block; anything else does not. + */ +const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]); + +interface Reservation { + stateId: string; + invoiceCounter: number; + previousIrn: string; +} + +/** + * Registers a single invoice with MoR EIMS. + * + * Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a + * committed transaction *before* the request leaves the process, and the network call happens + * outside any transaction. That gives three properties the naive design could not: + * + * - a counter is never reused once an attempt has begun, even across a crash; + * - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document + * that may already have reached MoR; + * - an ambiguous result blocks every invoice for the system number, not just its own, because + * `PreviousIrn` is unknown and any later document would chain to a stale IRN. + * + * Signing, authentication and error normalisation belong to `EimsClientService`. Manual only — + * nothing in invoice creation calls this. + */ +@Injectable() +export class EimsInvoiceRegistrationService { + private readonly logger = new Logger(EimsInvoiceRegistrationService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly client: EimsClientService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + async registerInvoiceWithEims(invoiceId: string): Promise { + const cfg = this.cfg; + // Static seller/tax configuration is validated before anything is locked, allocated or sent. + assertEimsInvoiceConfig(cfg); + + const invoice = await this.loadInvoiceForMapping(invoiceId); + if (invoice.eimsIrn) return this.toView(invoice); + + const reservation = await this.reserve(invoiceId, cfg.systemNumber); + if (!reservation) return this.getEimsStatus(invoiceId); + + // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. + const request = toEimsInvoice( + invoice, + buildEimsSeller(cfg), + buildEimsContext(cfg, { + // Our own invoice number is the document number; EIMS only requires it to be unique. + documentNumber: invoice.invoiceNumber, + invoiceCounter: reservation.invoiceCounter, + previousIrn: reservation.previousIrn, + }), + ); + + let irn: string; + let ackDate: string | undefined; + try { + // Deliberately outside every transaction — no DB lock is held across the wire. + const result = await this.submit(request); + irn = result.irn; + ackDate = result.ackDate; + } catch (err) { + await this.settleFailure(invoiceId, reservation, err); + throw err; + } + + await this.settleSuccess(invoiceId, reservation, irn, ackDate); + this.logger.log( + `Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`, + ); + return this.getEimsStatus(invoiceId); + } + + /** + * Verify a registered invoice at `POST /v1/verify`. + * + * Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled + * here — the gateway offers no lookup by document number — so it must be resolved with MoR and + * recorded through `resolveEimsRegistration`. + */ + async verifyInvoiceWithEims(invoiceId: string): Promise { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + if (!invoice.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_NO_IRN", + message: + `Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` + + "EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.", + }); + } + return this.queryVerify(invoice.eimsIrn); + } + + /** + * `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo + * an `Irn` back. A 200 without it is not a confirmation of anything. + * + * The request property is lowercase `irn`; the response spells it `Irn`. The two are never + * compared — the supplied collection's own fixture uses different example values on each side, + * so equality there would assert a property of the mock rather than of the gateway. + * + * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + */ + private async queryVerify(irn: string): Promise { + const response = await this.client.postBearer( + "/v1/verify", + { irn }, + ); + if (!response?.body?.Irn?.trim()) { + throw new EimsApiException( + "SCHEMA_VALIDATION", + "EIMS verify returned no Irn in its response body", + response?.statusCode, + ); + } + return response; + } + + /** + * Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice. + * + * The check is on `DocumentDetails.DocumentNumber`, which registration set from our own + * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + */ + private async assertIrnBelongsToInvoice( + irn: string, + expectedDocumentNumber: string, + ): Promise { + const response = await this.queryVerify(irn); + const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + + if (documentNumber !== expectedDocumentNumber) { + throw new ConflictException({ + code: "EIMS_RESOLVE_DOCUMENT_MISMATCH", + message: + `EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` + + `${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`, + }); + } + } + + /** + * Manual reconciliation of a blocked system number. + * + * With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain + * resumes from it. With `discard` the invoice is marked failed and the chain resumes from the + * previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result. + * + * An IRN is never taken on trust: it is verified at the gateway first, and the document it + * belongs to must be *this* invoice. A transposed digit would otherwise chain every later + * document to a stranger's IRN and mark this invoice registered when it is not. + */ + async resolveEimsRegistration( + invoiceId: string, + input: { irn?: string; discard?: boolean }, + ): Promise { + const irn = input.irn?.trim(); + if (!irn && !input.discard) { + throw new BadRequestException({ + code: "EIMS_RESOLVE_INPUT_REQUIRED", + message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission", + }); + } + + // Outside the transaction: no lock is held across the wire, and a refused verification must + // leave the block exactly as it was. + if (irn) { + const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId); + await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); + } + + await this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, this.cfg.systemNumber); + if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { + throw new ConflictException({ + code: "EIMS_RESOLVE_WRONG_INVOICE", + message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`, + }); + } + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) { + throw new ConflictException({ + code: "EIMS_ALREADY_REGISTERED", + message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`, + }); + } + + await manager.update(Invoice, invoiceId, { + eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed, + eimsIrn: irn ?? null, + }); + await manager.update(EimsSystemState, state.id, { + // Only a confirmed IRN may advance the chain; a discard leaves it where it was. + ...(irn ? { previousIrn: irn } : {}), + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + + this.logger.warn( + `EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`, + ); + return this.getEimsStatus(invoiceId); + } + + async getEimsStatus(invoiceId: string): Promise { + return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId)); + } + + // ── transactions ───────────────────────────────────────────────────────────────────────────── + + /** + * TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null` + * when the invoice turned out to be registered already (checked under the lock). + */ + private async reserve(invoiceId: string, systemNumber: string): Promise { + return this.dataSource.transaction(async (manager) => { + const state = await this.lockSystemState(manager, systemNumber); + + if (state.blockedReason) { + throw new ConflictException({ + code: "EIMS_SYSTEM_BLOCKED", + message: + `EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` + + "Resolve the affected invoice before registering anything else.", + }); + } + if (state.inFlightInvoiceId) { + throw new ConflictException({ + code: "EIMS_SUBMISSION_IN_FLIGHT", + message: + `A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` + + `${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`, + }); + } + + const invoice = await this.lockInvoice(manager, invoiceId); + if (invoice.eimsIrn) return null; + + const invoiceCounter = Number(state.nextInvoiceCounter); + const previousIrn = state.previousIrn ?? ""; + + // Counter consumed here, not on success: once an attempt begins it can never be reused, + // whatever happens next. A gap is harmless at MoR; a collision is not. + await manager.update(EimsSystemState, state.id, { + nextInvoiceCounter: invoiceCounter + 1, + inFlightInvoiceId: invoiceId, + inFlightCounter: invoiceCounter, + }); + await manager.update(Invoice, invoiceId, { + eimsStatus: EimsInvoiceStatus.Submitting, + eimsInvoiceCounter: invoiceCounter, + eimsSubmittedAt: new Date(), + eimsLastError: null, + }); + + return { stateId: state.id, invoiceCounter, previousIrn }; + }); + } + + /** TX2a. Record the IRN, advance the chain, release the reservation. */ + private async settleSuccess( + invoiceId: string, + reservation: Reservation, + irn: string, + ackDate?: string, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await this.lockInvoice(manager, invoiceId); + await manager.update(Invoice, invoiceId, { + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: irn, + eimsAckDate: ackDate ?? null, + eimsLastError: null, + }); + await manager.update(EimsSystemState, reservation.stateId, { + previousIrn: irn, + inFlightInvoiceId: null, + inFlightCounter: null, + blockedReason: null, + }); + }); + } + + /** + * TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and + * blocks the system number, because `PreviousIrn` is now unknown for every later document. + * The counter stays consumed either way. + */ + private async settleFailure( + invoiceId: string, + reservation: Reservation, + err: unknown, + ): Promise { + const api = err instanceof EimsApiException ? err : null; + const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; + const lastError: EimsInvoiceError = { + kind: api?.kind ?? "UNKNOWN", + message: (err as Error)?.message ?? "unknown error", + httpStatus: api?.httpStatus, + details: api?.details, + at: new Date().toISOString(), + }; + + await this.dataSource.transaction(async (manager) => { + await manager.update(Invoice, invoiceId, { + eimsStatus: status, + eimsLastError: lastError, + } as QueryDeepPartialEntity); + + await manager.update( + EimsSystemState, + reservation.stateId, + deterministic + ? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null } + : { + blockedReason: + `Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` + + `never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` + + "can be chained until it is resolved with MoR.", + }, + ); + }); + + this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`); + } + + // ── internals ──────────────────────────────────────────────────────────────────────────────── + + /** A non-empty IRN is the only success signal; anything else is a failed registration. */ + private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> { + const response = await this.client.postSigned( + "/v1/register", + request, + ); + const irn = response?.body?.irn; + if (!irn) { + // The gateway answered, so this is deterministic: the document is not registered. + throw new EimsApiException( + "SCHEMA_VALIDATION", + `EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`, + response?.statusCode, + ); + } + return { irn, ackDate: response.body?.ackDate }; + } + + private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :invoiceId", { invoiceId }) + .getOne(); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + /** Locks the system-state row, creating it on first use. */ + private async lockSystemState( + manager: EntityManager, + systemNumber: string, + ): Promise { + const select = () => + manager + .createQueryBuilder(EimsSystemState, "state") + .setLock("pessimistic_write") + .where("state.system_number = :systemNumber", { systemNumber }) + .getOne(); + + const existing = await select(); + if (existing) return existing; + + await manager.query( + `INSERT INTO freight.eims_system_state (system_number) VALUES ($1) + ON CONFLICT (system_number) DO NOTHING`, + [systemNumber], + ); + const created = await select(); + if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`); + return created; + } + + /** Header + buyer + lines — everything the mapper needs. */ + private async loadInvoiceForMapping( + invoiceId: string, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId }, + relations: { company: true, companyProfile: true }, + }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + + const lines: EimsMapperLine[] = await this.dataSource.query( + `SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate", + amount, currency, metadata + FROM freight.invoice_lines + WHERE invoice_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC`, + [invoiceId], + ); + return Object.assign(invoice, { lines }); + } + + private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise { + const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + return invoice; + } + + private toView(invoice: Invoice): EimsInvoiceStatusView { + const counter = invoice.eimsInvoiceCounter; + return { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, + eimsIrn: invoice.eimsIrn ?? null, + eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), + eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, + eimsAckDate: invoice.eimsAckDate ?? null, + eimsLastError: invoice.eimsLastError ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts new file mode 100644 index 000000000..9dab21107 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; + +/** + * Staff-triggered EIMS actions on an existing invoice. Registration is manual and one invoice at a + * time — nothing in invoice creation submits automatically. + * + * Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key: + * registration is irreversible at MoR, so it must not follow from the right to download a PDF. + * The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via + * ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS. + */ +@ApiTags("eims") +@ApiBearerAuth() +@Controller("invoices") +export class EimsInvoiceController { + constructor(private readonly registration: EimsInvoiceRegistrationService) {} + + @Post(":id/eims/register") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.", + }) + register(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.registerInvoiceWithEims(id); + } + + @Post(":id/eims/verify") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" }) + verify(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.verifyInvoiceWithEims(id); + } + + @Post(":id/eims/resolve") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.", + }) + resolve( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ResolveEimsRegistrationDto, + ) { + return this.registration.resolveEimsRegistration(id, dto); + } + + @Get(":id/eims/status") + @BookingStaff(FREIGHT_PERMS.invoices.view) + @ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" }) + status(@Param("id", ParseUUIDPipe) id: string) { + return this.registration.getEimsStatus(id); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts new file mode 100644 index 000000000..ad6a3aa34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -0,0 +1,87 @@ +import { EimsErrorResponse } from "./eims.types"; + +/** + * Registration state of one invoice at MoR EIMS. + * + * `UNKNOWN` is not a synonym for failure: the request left this process and no answer came back, + * so the invoice may or may not be registered at the gateway. It is never auto-retried — a resend + * would risk a duplicate registration. + */ +export enum EimsInvoiceStatus { + NotSubmitted = "NOT_SUBMITTED", + Submitting = "SUBMITTING", + Registered = "REGISTERED", + Failed = "FAILED", + Unknown = "UNKNOWN", +} + +/** `body` of a successful `POST /v1/register`, as observed in the collection. */ +export interface EimsRegisterResponseBody { + irn: string; + ackDate?: string; + signedQR?: string; + signedInvoice?: string; + status?: string; + documentNumber?: string; + errorMessage?: string | null; +} + +export interface EimsRegisterResponse { + statusCode?: number; + message?: string; + body?: EimsRegisterResponseBody; +} + +/** + * Inner request of `POST /v1/verify`. The wire property is lowercase `irn` and is required — + * omitting it yields a 400 "SCHEMA ERROR" reporting `$: required property 'irn' not found`. + */ +export interface EimsVerifyRequest { + irn: string; +} + +/** + * `body` of a successful `POST /v1/verify` — the stored document echoed back. Note the casing + * flip against the request: the response spells the reference `Irn`. + * + * Only the fields we actually assert on are typed; the rest of the echoed document (SellerDetails, + * BuyerDetails, ItemList, …) is carried through untyped because nothing here reads it. + */ +export interface EimsVerifyResponseBody { + Irn?: string; + TransactionType?: string; + DocumentDetails?: { + Type?: string; + DocumentNumber?: string; + Date?: string; + }; + Version?: string; + [section: string]: unknown; +} + +export interface EimsVerifyResponse { + statusCode?: number; + message?: string; + body?: EimsVerifyResponseBody; +} + +/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ +export interface EimsInvoiceError { + kind: string; + message: string; + httpStatus?: number; + details?: EimsErrorResponse; + at: string; +} + +/** What the status endpoint returns, and what a later invoice-detail panel will render. */ +export interface EimsInvoiceStatusView { + invoiceId: string; + invoiceNumber: string; + eimsStatus: EimsInvoiceStatus; + eimsIrn: string | null; + eimsInvoiceCounter: number | null; + eimsSubmittedAt: Date | null; + eimsAckDate: string | null; + eimsLastError: EimsInvoiceError | null; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 4c953c14e..c3b50a489 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -1,19 +1,35 @@ import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { Invoice } from "../billing/entities/invoice.entity"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; +import { EimsInvoiceController } from "./eims-invoice.controller"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsSignerService } from "./eims-signer.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; /** - * MoR EIMS e-invoicing transport. Exports only what other modules will consume; the credential - * loader and signer stay internal so the private key has exactly one user. + * MoR EIMS e-invoicing: signed transport, authentication, and manual single-invoice registration. + * + * Exports only what other modules will consume; the credential loader and signer stay internal so + * the private key has exactly one user. Nothing here is called from invoice creation. */ @Module({ imports: [ HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), + TypeOrmModule.forFeature([EimsSystemState, Invoice]), ], - providers: [EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService], - exports: [EimsAuthService, EimsClientService], + controllers: [EimsInvoiceController], + providers: [ + EimsCredentialsProvider, + EimsSignerService, + EimsAuthService, + EimsClientService, + EimsInvoiceRegistrationService, + ], + exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], }) export class EimsModule {} diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts new file mode 100644 index 000000000..ac6489c93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -0,0 +1,42 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * One row per MoR system number, holding the sequence state EIMS expects across registrations: + * the next `SourceSystem.InvoiceCounter` and the IRN that the next document must chain to via + * `ReferenceDetails.PreviousIrn`. + * + * Registration locks this row `FOR UPDATE` for the duration of the submission, which is what keeps + * two concurrent registrations from claiming the same counter or breaking the IRN chain. + */ +@Entity({ schema: "freight", name: "eims_system_state" }) +export class EimsSystemState extends BaseEntity { + @Column({ name: "system_number", type: "varchar", length: 32, unique: true }) + systemNumber!: string; + + /** Counter to send on the next registration; advanced only once an attempt has consumed it. */ + @Column({ name: "next_invoice_counter", type: "bigint", default: 1 }) + nextInvoiceCounter!: number; + + /** IRN of the last successful registration; null until the first one succeeds. */ + @Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true }) + previousIrn?: string | null; + + /** + * Invoice holding the current reservation. Committed before the HTTP call, so it survives a + * crash and blocks a blind resubmission of a document that may already have reached MoR. + */ + @Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true }) + inFlightInvoiceId?: string | null; + + /** Counter handed to the in-flight submission. */ + @Column({ name: "in_flight_counter", type: "bigint", nullable: true }) + inFlightCounter?: number | null; + + /** + * Why registration is blocked for this system number. Set when a submission ends ambiguously: + * the IRN is unknown, so no further document can chain correctly until it is resolved. + */ + @Column({ name: "blocked_reason", type: "text", nullable: true }) + blockedReason?: string | null; +} 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 92df72607..7fa994060 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -467,6 +467,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:export", "Download invoice document", ), + // Filing with the tax authority is its own grant: registration is irreversible at MoR, so it + // must not ride along with the right to download an invoice PDF. + perm( + "d2b00001-0001-4000-8000-000000000005", + "edr_freight_app:invoices:eims_register", + "Register invoice with MoR EIMS", + ), ]; // E. First / last mile operations @@ -1591,6 +1598,7 @@ export const FREIGHT_PERMS = { invoices: { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", + eimsRegister: "edr_freight_app:invoices:eims_register", }, firstMile: { view: "edr_freight_app:first_mile:view", From eadecf3fcf9d45d0e10df34b3ea22c444e54017c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:30:24 +0000 Subject: [PATCH 3/7] chore(eims): default the EIMS tax treatment to 0 Set EIMS_TAX_CODE=0 and EIMS_TAX_RATE_PERCENT=0 in .env.example as instructed. Every line is emitted with TaxAmount 0 and TotalLineAmount equal to PreTaxValue. The collection's only observed TaxCode is "VAT15", so "0" is unverified against the gateway and may draw a 406 rule-validation error. Both values are env-only, so correcting them needs no code change. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index a11b98520..0df076f57 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -164,8 +164,8 @@ 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. -EIMS_TAX_CODE= -EIMS_TAX_RATE_PERCENT= +EIMS_TAX_CODE=0 +EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 From 2e7ef40d9edb36ff14b02665119771d5ebf6561c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 13:50:05 +0000 Subject: [PATCH 4/7] feat(eims): take the source system from the access token MoR stamps systemNumber and systemType into the access token it issues for the authenticating credentials, which makes the token the authority on them. Registration now reads both from there instead of from configuration, so the SourceSystem block cannot drift from what the gateway believes we are. EimsAuthService decodes the token payload after login, requires both claims to be non-empty, and exposes them through getSessionContext(). The token is decoded but never verified -- it is MoR's, signed with MoR's key -- and is kept out of the log line, which names only the system it identified. EIMS_SYSTEM_NUMBER and EIMS_SYSTEM_TYPE become optional expectations rather than inputs: when set they are compared against the claims and a mismatch fails fast, so neither side silently wins. Neither is required to register any more. Registration and manual resolution both resolve the session before touching the state row, which is keyed by the system number: a login failure now costs nothing because no counter has been reserved yet. Test fixtures move to eims-test-fixtures.ts. They previously lived in eims-auth.service.spec.ts, which made jest execute that suite again inside every importing spec. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 4 +- .../edr-freight-api/src/config/eims.config.ts | 9 +- .../modules/eims/eims-auth.service.spec.ts | 155 +++++++++++------- .../src/modules/eims/eims-auth.service.ts | 98 ++++++++++- .../src/modules/eims/eims-invoice-context.ts | 15 +- .../eims-invoice-registration.service.spec.ts | 88 ++++++++-- .../eims/eims-invoice-registration.service.ts | 35 +++- .../src/modules/eims/eims-test-fixtures.ts | 75 +++++++++ 8 files changed, 391 insertions(+), 88 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 0df076f57..26641ee70 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -135,7 +135,9 @@ EIMS_CLIENT_ID= EIMS_CLIENT_SECRET= EIMS_API_KEY= EIMS_TIN= -# MoR-issued source-system identifiers (used once invoice registration lands) +# Source-system identity comes from the access token's systemNumber/systemType claims. +# Setting these turns them into expected-value checks: a mismatch against the token fails +# fast rather than one side silently winning. Leave empty to take the gateway's word. EIMS_SYSTEM_NUMBER= EIMS_SYSTEM_TYPE= # Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 4e8684a5b..0cadb55fb 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -16,7 +16,14 @@ export interface EimsConfig { clientSecret: string; apiKey: string; tin: string; - /** MoR-issued source-system identifiers; unused until invoice registration lands. */ + /** + * Optional *expectations* for the source-system identity, not inputs. + * + * The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials + * that authenticated, and those are what registration uses. When these are set they are compared + * against the token and a mismatch fails fast — neither side silently wins. Leave them empty to + * take whatever the gateway says. + */ systemNumber: string; systemType: string; /** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts index 75a702e94..1bed1fe29 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts @@ -2,57 +2,18 @@ import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; -import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; +import { EimsConfig } from "../../config/eims.config"; +import { eimsConfig, eimsToken } from "./eims-test-fixtures"; import { EimsAuthService } from "./eims-auth.service"; import { EimsSignerService } from "./eims-signer.service"; const CLIENT_SECRET = "super-secret-value"; const API_KEY = "super-secret-apikey"; -const cfg = (over: Partial = {}): EimsConfig => ({ - enabled: true, - baseUrl: "https://core.mor.gov.et", - clientId: "cid", - clientSecret: CLIENT_SECRET, - apiKey: API_KEY, - tin: "0000034558", - systemNumber: "B0360154BA", - systemType: "SYS", - privateKeyPath: "/dev/null", - certificatePath: "/dev/null", - httpTimeoutMs: 30_000, - tokenSkewMs: 45_000, - invoice: eimsInvoiceConfig(), - ...over, -}); +const cfg = (over: Partial = {}): EimsConfig => eimsConfig(over); -/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */ -export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ - sellerLegalName: "Ethio-Djibouti Railway S.C.", - sellerVatNumber: "0000000000", - sellerPhone: "0911223344", - sellerEmail: "finance@example.et", - sellerRegion: "13", - sellerWereda: "574", - sellerCity: null, - sellerSubCity: null, - sellerHouseNumber: null, - sellerLocality: null, - taxCode: "VAT15", - taxRatePercent: 15, - exciseTaxValue: 0, - incomeWithholdValue: 0, - transactionWithholdValue: 0, - transactionType: "B2B", - natureOfSupplies: "Service", - paymentMode: "CASH", - paymentTerm: "IMMIDIATE", - unitDefault: "PCS", - buyerCountryCode: null, - cashierName: null, - salesPersonName: null, - ...over, -}); +const TOKEN_1 = eimsToken({ jti: "one" }); +const TOKEN_2 = eimsToken({ jti: "two" }); const loginBody = (accessToken: string, expiresIn = 3600) => ({ data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn }, @@ -82,7 +43,7 @@ const axiosErr = (status: number, data: unknown) => describe("EimsAuthService.getValidAccessToken", () => { it("posts the signed login envelope to /auth/login with no Authorization header", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); await build(post).getValidAccessToken(); @@ -101,38 +62,38 @@ describe("EimsAuthService.getValidAccessToken", () => { }); it("returns the access token from data.accessToken", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); - await expect(build(post).getValidAccessToken()).resolves.toBe("token-1"); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1); }); it("reuses a cached token instead of logging in again", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); await auth.getValidAccessToken(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); expect(post).toHaveBeenCalledTimes(1); }); it("re-authenticates a skew-window before the token actually expires", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); const auth = build(post); const start = Date.now(); const clock = jest.spyOn(Date, "now"); try { clock.mockReturnValue(start); - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); clock.mockReturnValue(start + 50_000); // inside the window: still cached - await expect(auth.getValidAccessToken()).resolves.toBe("token-1"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1); expect(post).toHaveBeenCalledTimes(1); clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry - await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); } finally { clock.mockRestore(); @@ -142,24 +103,38 @@ describe("EimsAuthService.getValidAccessToken", () => { it("logs in again after invalidate()", async () => { const post = jest .fn() - .mockReturnValueOnce(of({ data: loginBody("token-1") })) - .mockReturnValueOnce(of({ data: loginBody("token-2") })); + .mockReturnValueOnce(of({ data: loginBody(TOKEN_1) })) + .mockReturnValueOnce(of({ data: loginBody(TOKEN_2) })); const auth = build(post); await auth.getValidAccessToken(); auth.invalidate(); - await expect(auth.getValidAccessToken()).resolves.toBe("token-2"); + await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2); expect(post).toHaveBeenCalledTimes(2); }); it("performs exactly one login for many concurrent callers", async () => { - const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") })); + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); const auth = build(post); const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken())); expect(post).toHaveBeenCalledTimes(1); - expect(new Set(tokens)).toEqual(new Set(["token-1"])); + expect(new Set(tokens)).toEqual(new Set([TOKEN_1])); + }); + + it("does not put the access token in its own log line", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const logged: string[] = []; + const auth = build(post); + jest + .spyOn(auth["logger"], "log") + .mockImplementation((message: unknown) => void logged.push(String(message))); + + await auth.getValidAccessToken(); + + expect(logged.join("\n")).not.toContain(TOKEN_1); + expect(logged.join("\n")).toContain("B0360154BA"); }); it("refuses to call the gateway when EIMS is disabled", async () => { @@ -217,3 +192,65 @@ describe("EimsAuthService.getValidAccessToken", () => { await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/); }); }); + +describe("EimsAuthService.getSessionContext", () => { + it("takes the source system from the token's claims", async () => { + const post = jest + .fn() + .mockReturnValue( + of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }), + ); + + // Env deliberately left empty: with nothing to check against, the token is simply believed. + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" }); + }); + + it("serves the session from the cached login rather than re-authenticating", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + const auth = build(post); + + await auth.getSessionContext(); + await expect(auth.getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + expect(post).toHaveBeenCalledTimes(1); + }); + + it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => { + const post = jest + .fn() + .mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) })); + + await expect( + build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(), + ).rejects.toThrow(new RegExp(`no ${claim} claim`)); + }); + + it("rejects an access token that is not a decodable JWT", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") })); + + await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/); + }); + + it.each([ + ["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/], + ["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/], + ])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) })); + + // cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations. + await expect(build(post).getSessionContext()).rejects.toThrow(pattern); + }); + + it("accepts a configured value that matches the token", async () => { + const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) })); + + await expect(build(post).getSessionContext()).resolves.toEqual({ + systemNumber: "B0360154BA", + systemType: "SYS", + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts index 99af70c57..9e53723d0 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-auth.service.ts @@ -11,8 +11,43 @@ interface TokenCache { accessToken: string; /** Epoch ms, already reduced by the configured skew. */ expiresAt: number; + session: EimsSessionContext; } +/** + * Source-system identity, taken from the access token MoR issues us. + * + * The gateway stamps `systemNumber` and `systemType` into the token for the credentials that + * authenticated, which makes the token the authority on them — not our environment file. Anything + * we configured locally can only ever disagree with what MoR believes. + */ +export interface EimsSessionContext { + systemNumber: string; + systemType: string; +} + +/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */ +function decodeTokenClaims(accessToken: string): Record { + const payload = accessToken.split(".")[1]; + if (!payload) { + throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)"); + } + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch (err) { + // The token itself is never included — only that its payload would not parse. + throw new EimsApiException( + "UNKNOWN", + `EIMS access token payload could not be decoded: ${(err as Error).message}`, + ); + } +} + +const claimString = (claims: Record, name: string): string => { + const value = claims[name]; + return typeof value === "string" ? value.trim() : ""; +}; + /** Used when the gateway omits `expiresIn`; the observed value is 3600. */ const FALLBACK_EXPIRES_IN_SECONDS = 3600; @@ -57,11 +92,64 @@ export class EimsAuthService { } } + /** + * The source-system identity MoR issued this session, refreshing the login if needed. + * + * This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both + * into the access token for the authenticating credentials, so a local env value could only ever + * disagree with it. + */ + async getSessionContext(): Promise { + await this.getValidAccessToken(); + return this.cache!.session; + } + /** Drop the cached token — called after a 401 so the next request re-authenticates. */ invalidate(): void { this.cache = null; } + /** + * Read the source-system claims out of the token, and cross-check anything configured locally. + * + * `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they + * are compared and a mismatch fails immediately rather than one silently winning. Registering + * under the wrong source system is not something to discover from a rejected invoice. + */ + private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext { + const claims = decodeTokenClaims(accessToken); + const systemNumber = claimString(claims, "systemNumber"); + const systemType = claimString(claims, "systemType"); + + const missing = [ + !systemNumber && "systemNumber", + !systemType && "systemType", + ].filter(Boolean); + if (missing.length > 0) { + throw new EimsApiException( + "UNKNOWN", + `EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`, + ); + } + + const mismatches = [ + cfg.systemNumber && cfg.systemNumber !== systemNumber + ? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}` + : null, + cfg.systemType && cfg.systemType !== systemType + ? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}` + : null, + ].filter(Boolean); + if (mismatches.length > 0) { + throw new EimsConfigException( + `EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` + + "Correct the environment or the credentials — neither value is assumed to win.", + ); + } + + return { systemNumber, systemType }; + } + private async login(): Promise { const cfg = this.cfg; if (!cfg.enabled) { @@ -106,11 +194,19 @@ export class EimsAuthService { // examples of calls that do require signing, so whether refresh must be signed is unconfirmed. // Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s, // so that is one extra call an hour. + // Reject the session before caching it: a token we cannot identify a source system from is + // useless for registration, and a configured expectation that disagrees is a deployment fault. + const session = this.readSessionContext(accessToken, cfg); + this.cache = { accessToken, expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000), + session, }; - this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`); + this.logger.log( + `EIMS login succeeded; token cached for ~${expiresIn}s ` + + `(system ${session.systemNumber}, type ${session.systemType})`, + ); return accessToken; } } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 1a68f2ce4..20ccfdfba 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { EimsConfig } from "../../config/eims.config"; +import { EimsSessionContext } from "./eims-auth.service"; import { EimsMapperContext, EimsMapperLine, @@ -21,10 +22,10 @@ interface RequiredSpec { value: string | number | null | undefined; } -const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [ +// `systemNumber` / `systemType` are absent by design: they come from the access token, which is +// MoR's own statement of who we are. See EimsAuthService.getSessionContext. +const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [ { env: "EIMS_TIN", value: tin }, - { env: "EIMS_SYSTEM_NUMBER", value: systemNumber }, - { env: "EIMS_SYSTEM_TYPE", value: systemType }, { env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName }, { env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber }, { env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone }, @@ -44,7 +45,7 @@ const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: str /** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */ export function assertEimsInvoiceConfig(config: EimsConfig): void { - const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType) + const missing = REQUIRED(config.invoice, config.tin) .filter(({ value }) => value === null || value === undefined || value === "") .map(({ env }) => env); @@ -80,6 +81,8 @@ export interface EimsContextInput { documentNumber: string; invoiceCounter: number; previousIrn: string | null; + /** Source-system identity from the access token, never from configuration. */ + session: EimsSessionContext; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; } @@ -92,8 +95,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E const exciseTaxValue = invoice.exciseTaxValue ?? 0; return { - systemNumber: config.systemNumber, - systemType: config.systemType, + systemNumber: input.session.systemNumber, + systemType: input.session.systemType, documentNumber: input.documentNumber, invoiceCounter: input.invoiceCounter, previousIrn: input.previousIrn, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 362c754cb..1035c2b33 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -5,7 +5,8 @@ import { DataSource } from "typeorm"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; -import { eimsInvoiceConfig } from "./eims-auth.service.spec"; +import { eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; @@ -138,25 +139,35 @@ class FakeDb { } } +/** The source system comes from the access token, so the service is handed a session, not config. */ +const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" }; + const build = ( db: FakeDb, postSigned: jest.Mock, cfg: EimsConfig = config(), postBearer: jest.Mock = jest.fn(), + getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION), ) => new EimsInvoiceRegistrationService( db.asDataSource(), { get: () => cfg } as unknown as ConfigService, { postSigned, postBearer } as unknown as EimsClientService, + { getSessionContext } as unknown as EimsAuthService, ); /** Document number the fixtures register under; `/v1/verify` must echo it back. */ const DOCUMENT_NUMBER = "INV-20260807-00042"; /** - * `/v1/verify` success. The response spells the reference `Irn` while the request uses `irn`, and - * the collection's own fixture uses a *different* example value on each side — so nothing here - * assumes the two match. + * `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase + * `irn`. + * + * The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection + * pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of + * the mock rather than gateway behaviour; asserting against that inconsistency would encode the + * mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for, + * and these fixtures exercise that honestly. */ const verifyResponse = (over: Record = {}) => ({ statusCode: 200, @@ -213,6 +224,43 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER); }); + it("takes SourceSystem from the token session, not from configuration", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue(okResponse()); + // Config disagrees on purpose: only the session may reach the wire. + const cfg = config(); + (cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY"; + (cfg as { systemType: string }).systemType = "MAN"; + + await build( + db, + postSigned, + cfg, + jest.fn(), + jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }), + ).registerInvoiceWithEims(INVOICE_ID); + + const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest; + expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN"); + expect(request.SourceSystem.SystemType).toBe("POS"); + }); + + it("does not consume a counter when authentication fails", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn(); + const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed")); + + await expect( + build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims( + INVOICE_ID, + ), + ).rejects.toThrow(/login failed/); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null }); + expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted); + }); + it("is idempotent — an invoice with an IRN never reaches EIMS", async () => { const db = new FakeDb([ invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), @@ -377,17 +425,6 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { expect(result.body).toMatchObject({ Irn: IRN }); }); - it("accepts a response whose Irn differs from the one sent", async () => { - // The supplied collection's own fixture does exactly this; equality would assert a property - // of the mock, not of the gateway. - const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" })); - - await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), - ).resolves.toMatchObject({ body: { Irn: "a-different-irn" } }); - }); - it("rejects a 200 that carries no Irn", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); @@ -436,6 +473,27 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { }); }); + it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { + const db = blocked(); + const postBearer = jest + .fn() + .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); + + await expect( + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + ).rejects.toThrow(/answered the lookup for IRN/); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Unknown, + eimsIrn: null, + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: INVOICE_ID, + blockedReason: "never acknowledged", + previousIrn: null, + }); + }); + it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); const postBearer = jest.fn().mockResolvedValue( diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 44b92cd9b..4ff9ccfb8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -17,6 +17,7 @@ import { EimsMapperLine, toEimsInvoice, } from "../billing/eims-invoice.mapper"; +import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -70,6 +71,7 @@ export class EimsInvoiceRegistrationService { @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, private readonly client: EimsClientService, + private readonly auth: EimsAuthService, ) {} private get cfg(): EimsConfig { @@ -84,7 +86,11 @@ export class EimsInvoiceRegistrationService { const invoice = await this.loadInvoiceForMapping(invoiceId); if (invoice.eimsIrn) return this.toView(invoice); - const reservation = await this.reserve(invoiceId, cfg.systemNumber); + // Authenticate before reserving: the source system comes from the token, and the state row is + // keyed by it. A login failure here costs nothing — no counter has been consumed yet. + const session = await this.auth.getSessionContext(); + + const reservation = await this.reserve(invoiceId, session.systemNumber); if (!reservation) return this.getEimsStatus(invoiceId); // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. @@ -96,6 +102,7 @@ export class EimsInvoiceRegistrationService { documentNumber: invoice.invoiceNumber, invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, + session, }), ); @@ -164,18 +171,33 @@ export class EimsInvoiceRegistrationService { } /** - * Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice. + * Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this + * IRN is the one it holds, and that it belongs to this invoice. * - * The check is on `DocumentDetails.DocumentNumber`, which registration set from our own - * `invoiceNumber`. That is the only field tying an IRN back to a row in this database. + * The document-number check is against `DocumentDetails.DocumentNumber`, which registration set + * from our own `invoiceNumber` — the only field tying an IRN back to a row in this database. + * + * Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and + * chains every later document to a stranger's reference, so both checks are refusals rather + * than warnings. */ private async assertIrnBelongsToInvoice( irn: string, expectedDocumentNumber: string, ): Promise { const response = await this.queryVerify(irn); + const returnedIrn = response.body?.Irn?.trim(); const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim(); + if (returnedIrn !== irn) { + throw new ConflictException({ + code: "EIMS_RESOLVE_IRN_MISMATCH", + message: + `EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` + + "Refusing to record it — recheck the IRN in the MoR portal.", + }); + } + if (documentNumber !== expectedDocumentNumber) { throw new ConflictException({ code: "EIMS_RESOLVE_DOCUMENT_MISMATCH", @@ -216,8 +238,11 @@ export class EimsInvoiceRegistrationService { await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); } + // Same source of truth as registration: the state row is keyed by the token's system number. + const session = await this.auth.getSessionContext(); + await this.dataSource.transaction(async (manager) => { - const state = await this.lockSystemState(manager, this.cfg.systemNumber); + const state = await this.lockSystemState(manager, session.systemNumber); if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) { throw new ConflictException({ code: "EIMS_RESOLVE_WRONG_INVOICE", diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts new file mode 100644 index 000000000..f411c8b44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -0,0 +1,75 @@ +import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config"; + +/** + * Fixtures shared by the EIMS specs. + * + * Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that + * file's `describe` blocks inside every importing suite, so the same tests run — and report — + * twice. + */ + +export const EIMS_SYSTEM_NUMBER = "B0360154BA"; +export const EIMS_SYSTEM_TYPE = "SYS"; + +export const eimsInvoiceConfig = (over: Partial = {}): EimsInvoiceConfig => ({ + sellerLegalName: "Ethio-Djibouti Railway S.C.", + sellerVatNumber: "0000000000", + sellerPhone: "0911223344", + sellerEmail: "finance@example.et", + sellerRegion: "13", + sellerWereda: "574", + sellerCity: null, + sellerSubCity: null, + sellerHouseNumber: null, + sellerLocality: null, + taxCode: "VAT15", + taxRatePercent: 15, + exciseTaxValue: 0, + incomeWithholdValue: 0, + transactionWithholdValue: 0, + transactionType: "B2B", + natureOfSupplies: "Service", + paymentMode: "CASH", + paymentTerm: "IMMIDIATE", + unitDefault: "PCS", + buyerCountryCode: null, + cashierName: null, + salesPersonName: null, + ...over, +}); + +export const eimsConfig = (over: Partial = {}): EimsConfig => ({ + enabled: true, + baseUrl: "https://core.mor.gov.et", + clientId: "cid", + clientSecret: "super-secret-value", + apiKey: "super-secret-apikey", + tin: "0000034558", + systemNumber: EIMS_SYSTEM_NUMBER, + systemType: EIMS_SYSTEM_TYPE, + privateKeyPath: "/dev/null", + certificatePath: "/dev/null", + httpTimeoutMs: 30_000, + tokenSkewMs: 45_000, + invoice: eimsInvoiceConfig(), + ...over, +}); + +/** + * A structurally real access token. MoR stamps the source-system identity into the JWT payload and + * `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is + * never verified locally — it is MoR's, signed with MoR's key. + * + * Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo). + */ +export const eimsToken = (claims: Record = {}): string => { + const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims }; + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) delete (payload as Record)[key]; + } + return [ + "eyJhbGciOiJSUzI1NiJ9", + Buffer.from(JSON.stringify(payload)).toString("base64url"), + "signature", + ].join("."); +}; From 02db3d2e73409a97f679301bc1b7bbfeb45d8d2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:42:24 +0000 Subject: [PATCH 5/7] fix(eims): keep EIMS filing off the Finance role preset Invoices are produced by the freight workflow, not by a person, so filing is not a Finance job function. The manual endpoints exist for controlled testing and exceptional operations, and are left out of every role preset so they are assigned to named admins instead. Split resolve onto its own permission, invoices:eims_resolve: resolving an unacknowledged submission clears the system-wide chain block and can record an IRN against an invoice, which is a supervisor action rather than an operational one. eims/status stays on the ordinary invoices:view. Co-Authored-By: Claude Opus 5 --- .../src/modules/eims/eims-invoice.controller.ts | 14 +++++++++++--- .../src/seed/freight-permissions.registry.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 9dab21107..f47cfe9d9 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -7,8 +7,16 @@ import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto" import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; /** - * Staff-triggered EIMS actions on an existing invoice. Registration is manual and one invoice at a - * time — nothing in invoice creation submits automatically. + * Manual EIMS actions on an existing invoice. + * + * Invoices are produced by the freight workflow, not by a person, so these routes are **not** the + * normal production path — they exist for controlled testing and exceptional operations. Automatic + * submission after an invoice is issued is a separate phase; nothing here is called by it. + * + * `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned + * to named admins instead. They are also separate permissions: resolving clears the system-wide + * chain block and can record an IRN against an invoice, which is a supervisor action, not an + * operational one. Only `eims/status` rides on the ordinary `invoices:view`. * * Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key: * registration is irreversible at MoR, so it must not follow from the right to download a PDF. @@ -39,7 +47,7 @@ export class EimsInvoiceController { } @Post(":id/eims/resolve") - @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @BookingStaff(FREIGHT_PERMS.invoices.eimsResolve) @ApiOperation({ summary: "Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.", 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 7fa994060..3c2be2e09 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -474,6 +474,14 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_register", "Register invoice with MoR EIMS", ), + // Separate from registering: resolving an unacknowledged submission clears the + // system-wide chain block and can record an IRN against an invoice, so it is a + // supervisor/admin action rather than an operational one. + perm( + "d2b00001-0001-4000-8000-000000000006", + "edr_freight_app:invoices:eims_resolve", + "Resolve a blocked MoR EIMS submission", + ), ]; // E. First / last mile operations @@ -1599,6 +1607,7 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", eimsRegister: "edr_freight_app:invoices:eims_register", + eimsResolve: "edr_freight_app:invoices:eims_resolve", }, firstMile: { view: "edr_freight_app:first_mile:view", @@ -2088,6 +2097,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve. + // Invoices are filed with MoR by the workflow, not by a person, so filing is not a + // Finance job function — the endpoints exist for controlled testing and exceptional + // operations, and are assigned to named admins rather than a role preset. FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, ], From 67573d0835db2719fc55daaf7d42daededf62fdb Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:58:32 +0000 Subject: [PATCH 6/7] feat(eims): file issued invoices on a cron sweep, off by default Invoices are produced by the freight workflow rather than by a person, so the production path for filing is a sweep, not the manual endpoint. A @Cron picks the oldest never-submitted invoice and hands it to the existing EimsInvoiceRegistrationService -- no registration logic is duplicated, and the durable reservation still decides whether the submission may proceed. Sweeping rather than hooking the eleven places an invoice can be created or issued keeps the workflow untouched, puts the HTTP call outside the invoice transaction by construction, and lets a crash or restart be picked up on the next tick. invoices.eims_status is the queue; nothing new is persisted. Only NOT_SUBMITTED is eligible: UNKNOWN is never retried automatically because the document may already be filed, and FAILED waits for an explicit retry policy. The tick also refuses to start while eims_system_state holds an in-flight submission or a block, and only one invoice is filed per tick so a misconfiguration costs one rejected document rather than a burst. Requires both EIMS_ENABLED and EIMS_AUTO_SUBMIT; the second defaults to false so authentication can be live long before filing is. Logs carry the invoice number, status and IRN only. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 7 + .../edr-freight-api/src/config/eims.config.ts | 21 +++ .../eims/eims-auto-submit.service.spec.ts | 139 ++++++++++++++++++ .../modules/eims/eims-auto-submit.service.ts | 126 ++++++++++++++++ .../src/modules/eims/eims-test-fixtures.ts | 3 + .../src/modules/eims/eims.module.ts | 2 + 6 files changed, 298 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 26641ee70..da0b1ceb9 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -181,3 +181,10 @@ EIMS_UNIT_DEFAULT=PCS EIMS_BUYER_COUNTRY_CODE= EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= +# Automatic filing of issued invoices (@Cron sweep, one invoice per tick). +# Independent of EIMS_ENABLED on purpose: authentication can be live long before +# filing is. Both must be true before anything is submitted automatically. +EIMS_AUTO_SUBMIT=false +EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * +# MoR rejects documents older than 3 days; the sweep will not attempt those. +EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 0cadb55fb..6eaaf8007 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -33,6 +33,18 @@ export interface EimsConfig { httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; + /** + * Automatic submission of issued invoices, off by default. + * + * Invoices are produced by the workflow, so the production path is a sweep rather than a human + * action — but enabling it starts filing real documents with the tax authority, which is + * irreversible from our side. It therefore needs its own deliberate switch, separate from + * `EIMS_ENABLED`, so that authentication can be live long before filing is. + */ + autoSubmit: boolean; + autoSubmitCron: string; + /** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */ + autoSubmitMaxAgeDays: number; /** * Seller identity and tax/business treatment for the invoice document. * @@ -119,6 +131,15 @@ export default registerAs("eims", (): EimsConfig => { certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", httpTimeoutMs, tokenSkewMs, + autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", + // Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a + // misconfiguration from filing a burst of bad documents before anyone notices. + autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *", + autoSubmitMaxAgeDays: positiveInt( + process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS, + 3, + "EIMS_AUTO_SUBMIT_MAX_AGE_DAYS", + ), invoice: { sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts new file mode 100644 index 000000000..6246d5a89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -0,0 +1,139 @@ +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { eimsConfig } from "./eims-test-fixtures"; + +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; + +/** + * `query` is answered by shape: the first call is the system-state guard, the second is the + * candidate lookup. Keeps the fake honest about the order the service actually asks in. + */ +const build = ( + opts: { + cfg?: Partial; + state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; + candidate?: { id: string; invoiceNumber: string } | null; + register?: jest.Mock; + } = {}, +) => { + const register = + opts.register ?? + jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" }); + + const query = jest.fn().mockImplementation((sql: string) => { + if (sql.includes("eims_system_state")) { + return Promise.resolve( + opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [], + ); + } + return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); + }); + + const service = new EimsAutoSubmitService( + { query } as unknown as DataSource, + { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, + { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, + ); + return { service, register, query }; +}; + +const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; + +describe("EimsAutoSubmitService.tick", () => { + it("files the oldest eligible invoice through the registration service", async () => { + const { service, register } = build({ candidate }); + + await service.tick(); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(INVOICE_ID); + }); + + it("files nothing when EIMS_AUTO_SUBMIT is off", async () => { + const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => { + const { service, register, query } = build({ cfg: { enabled: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("does not submit while another submission is in flight", async () => { + const { service, register } = build({ + state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does not submit while the system number is blocked", async () => { + const { service, register } = build({ + state: { blocked_reason: "never acknowledged" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does nothing when no invoice is eligible", async () => { + const { service, register } = build({ candidate: null }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => { + const { service, query } = build({ candidate }); + + await service.tick(); + + const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!; + expect(sql).toContain("i.eims_status = $1"); + expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted); + expect(sql).toContain("i.issued_at IS NOT NULL"); + }); + + it("survives a filing failure so the job keeps running", async () => { + const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)")); + const { service } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + expect(register).toHaveBeenCalledTimes(1); + }); + + it("does not start a second tick while one is still filing", async () => { + let release: () => void = () => {}; + const register = jest.fn().mockImplementation( + () => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))), + ); + const { service } = build({ candidate, register }); + + const first = service.tick(); + await new Promise((r) => setImmediate(r)); + await service.tick(); // overlapping tick, must be a no-op + + expect(register).toHaveBeenCalledTimes(1); + release(); + await first; + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts new file mode 100644 index 000000000..fb5a0d1f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron } from "@nestjs/schedule"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +/** + * Files issued invoices with MoR EIMS on a timer. + * + * Invoices are produced by the freight workflow rather than by a person, so this — not the manual + * endpoint — is the production path. It is a sweep rather than a hook on the eleven places an + * invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP + * call is by construction outside the invoice's transaction, and an invoice missed through a crash + * or a restart is picked up on the next tick. + * + * `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible: + * `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED` + * waits for an explicit retry policy rather than a timer's guess. + * + * Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing + * real documents with the tax authority, and a registration cannot be undone from this side. + */ +@Injectable() +export class EimsAutoSubmitService { + private readonly logger = new Logger(EimsAutoSubmitService.name); + /** Guards against a tick starting while the previous one is still filing. */ + private running = false; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly registration: EimsInvoiceRegistrationService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * One invoice per tick. + * + * Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an + * ambiguous result blocks the system number until a human resolves it, and a misconfiguration + * should cost one rejected document rather than a burst of them. + */ + @Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" }) + async tick(): Promise { + const cfg = this.cfg; + if (!cfg.enabled || !cfg.autoSubmit) return; + if (this.running) return; + + this.running = true; + try { + // Rule of the chain: nothing may be filed while a submission is in flight or the system is + // blocked. The reservation would refuse anyway — checking first keeps the log quiet and + // avoids burning a tick on a guaranteed conflict. + const blocked = await this.systemBlockReason(); + if (blocked) { + this.logger.warn(`EIMS auto-submit paused: ${blocked}`); + return; + } + + const candidate = await this.nextCandidate(); + if (!candidate) return; + + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Never let a filing failure kill the job. The outcome is already persisted on the invoice + // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the + // next tick at the guard above. + this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); + } finally { + this.running = false; + } + } + + /** Why filing is currently impossible for this system number, or null when it is free. */ + private async systemBlockReason(): Promise { + const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = + await this.dataSource.query( + `SELECT in_flight_invoice_id, blocked_reason + FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL + LIMIT 1`, + [this.cfg.systemNumber], + ); + const state = rows[0]; + if (!state) return null; + if (state.blocked_reason) return state.blocked_reason; + if (state.in_flight_invoice_id) { + return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`; + } + return null; + } + + /** + * Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and + * carries at least one line. + */ + private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> { + const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber" + FROM freight.invoices i + WHERE i.eims_status = $1 + AND i.issued_at IS NOT NULL + AND i.deleted_at IS NULL + AND i.issued_at > now() - ($2 || ' days')::interval + AND EXISTS ( + SELECT 1 FROM freight.invoice_lines l + WHERE l.invoice_id = i.id AND l.deleted_at IS NULL + ) + ORDER BY i.issued_at ASC + LIMIT 1`, + [EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays], + ); + return rows[0] ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index f411c8b44..79fe30f96 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -51,6 +51,9 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + autoSubmit: false, + autoSubmitCron: "0 */5 * * * *", + autoSubmitMaxAgeDays: 3, invoice: eimsInvoiceConfig(), ...over, }); diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index c3b50a489..678b21b52 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsAuthService } from "./eims-auth.service"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; @@ -29,6 +30,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsAutoSubmitService, ], exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], }) From b8e702dbc0e21748cf11569ddbfc881a78193eb2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 15:08:02 +0000 Subject: [PATCH 7/7] CAS total --- .../src/modules/bookings/bookings.service.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e4b902fe1..15799f000 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -424,7 +424,10 @@ export class BookingsService { const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; - const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + // Container bookings carry no cargo type or free text — name the freight type + // rather than printing a dash in the Cargo Name column. + const cargoName = + booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-'; const currency = booking.paymentCurrency ?? 'ETB'; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const prices = this.splitAmountAcrossWagons( @@ -467,6 +470,28 @@ export class BookingsService { ) .join(''); + // The totals belong in , not : the Chromium-less fallback + // renderer only parses tbody rows, so a silently drops every footer + // figure from the printed sheet. + const totalsRow = ` + TOT + ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${ + pendingWagons + ? 'pending marshalling' + : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + } + ${num(totals.tare, 2)} + ${num(totals.length)} + ${num(totals.capacity)} + + Gross ${num(totals.tare + totals.load)} T + + + + ${money(totalAmount)} + `; + return ` @@ -490,7 +515,7 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } - tfoot td { background: #f8fafc; font-weight: 700; } + tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } @@ -538,21 +563,8 @@ export class BookingsService { ${rows} + ${totalsRow} - - - ${ - pendingWagons - ? `Received lines: ${wagons.length} — wagons pending marshalling` - : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` - } - ${num(totals.tare, 2)} - ${num(totals.length)} - ${num(totals.capacity)} - Gross weight (tare + load): ${num(totals.tare + totals.load)} T - ${money(totalAmount)} - -