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 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-07 11:42:29 +00:00
parent 22e6e550bc
commit 2644d5e52d
17 changed files with 1523 additions and 1 deletions

9
.gitignore vendored
View File

@@ -53,3 +53,12 @@ RUNNING_LOCALLY.md
# Generated per-shard compose file for the integration suite (it.mjs). # Generated per-shard compose file for the integration suite (it.mjs).
integration/.it-shards.yaml 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/

View File

@@ -126,3 +126,56 @@ EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight). # Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. # Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me 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=

View File

@@ -37,7 +37,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js", "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": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",

View File

@@ -23,6 +23,7 @@ import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config"; import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config"; import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config"; import faydaConfig from "./config/fayda.config";
import eimsConfig from "./config/eims.config";
import { BookingsModule } from "./modules/bookings/bookings.module"; import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.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 //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.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 { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module"; import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module"; import { ContainersModule } from "./modules/container-management/containers.module";
@@ -127,6 +129,7 @@ if (!process.env.APPLICATION_NAME) {
telebirrConfig, telebirrConfig,
rabbitmqConfig, rabbitmqConfig,
faydaConfig, faydaConfig,
eimsConfig,
], ],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@@ -248,6 +251,7 @@ if (!process.env.APPLICATION_NAME) {
InterchangeDocumentsModule, InterchangeDocumentsModule,
ImportOperationsModule, ImportOperationsModule,
VerifaydaModule, VerifaydaModule,
EimsModule,
FleetHistoryModule, FleetHistoryModule,
AiModule, AiModule,
AuditModule, AuditModule,

View File

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

View File

@@ -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> = {}): 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> = {}): 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");
});
});

View File

@@ -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<string, unknown> | 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,
};
}

View File

@@ -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> = {}): 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: <T>(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/);
});
});

View File

@@ -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<string> | null = null;
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly signer: EimsSignerService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("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<string> {
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<string> {
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<EimsLoginResponse>(`${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;
}
}

View File

@@ -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<EimsConfig>("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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
return this.send<TRequest, TResponse>(path, request, false);
}
private async send<TRequest, TResponse>(
path: string,
request: TRequest,
isRetry: boolean,
): Promise<TResponse> {
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<TResponse>(`${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<TRequest, TResponse>(path, request, true);
}
this.logger.error(mapped.message);
throw mapped;
}
}
}

View File

@@ -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<EimsConfig>("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;
}
}

View File

@@ -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/);
});
});

View File

@@ -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<T>(request: T): EimsSignedRequest<T> {
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 = <T>(signed: EimsSignedRequest<T>): string => JSON.stringify(signed);

View File

@@ -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<string, unknown>;
const safe: Record<string, unknown> = {};
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"}`);
}

View File

@@ -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 {}

View File

@@ -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<T> {
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;
}

View File

@@ -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<void> {
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;
});