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; +});