mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
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:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
apps/edr-freight-api/src/config/eims.config.ts
Normal file
80
apps/edr-freight-api/src/config/eims.config.ts
Normal 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;
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
362
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal file
362
apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
190
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
116
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
116
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
67
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal file
37
apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Normal 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);
|
||||
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal file
89
apps/edr-freight-api/src/modules/eims/eims.errors.ts
Normal 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"}`);
|
||||
}
|
||||
19
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
19
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal 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 {}
|
||||
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal file
46
apps/edr-freight-api/src/modules/eims/eims.types.ts
Normal 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;
|
||||
}
|
||||
40
apps/edr-freight-api/src/scripts/eims-login.ts
Normal file
40
apps/edr-freight-api/src/scripts/eims-login.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user