mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1166 from Tria-plc/eims-integration
Eims integration
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,65 @@ 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=
|
||||
# Source-system identity comes from the access token's systemNumber/systemType claims.
|
||||
# Setting these turns them into expected-value checks: a mismatch against the token fails
|
||||
# fast rather than one side silently winning. Leave empty to take the gateway's word.
|
||||
EIMS_SYSTEM_NUMBER=
|
||||
EIMS_SYSTEM_TYPE=
|
||||
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file
|
||||
# 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=0
|
||||
EIMS_TAX_RATE_PERCENT=0
|
||||
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=
|
||||
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).
|
||||
# Independent of EIMS_ENABLED on purpose: authentication can be live long before
|
||||
# filing is. Both must be true before anything is submitted automatically.
|
||||
EIMS_AUTO_SUBMIT=false
|
||||
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
|
||||
# MoR rejects documents older than 3 days; the sweep will not attempt those.
|
||||
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
|
||||
|
||||
@@ -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,
|
||||
|
||||
185
apps/edr-freight-api/src/config/eims.config.ts
Normal file
185
apps/edr-freight-api/src/config/eims.config.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
/**
|
||||
* Optional *expectations* for the source-system identity, not inputs.
|
||||
*
|
||||
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
|
||||
* that authenticated, and those are what registration uses. When these are set they are compared
|
||||
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
|
||||
* take whatever the gateway says.
|
||||
*/
|
||||
systemNumber: string;
|
||||
systemType: string;
|
||||
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */
|
||||
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;
|
||||
/**
|
||||
* Automatic submission of issued invoices, off by default.
|
||||
*
|
||||
* Invoices are produced by the workflow, so the production path is a sweep rather than a human
|
||||
* action — but enabling it starts filing real documents with the tax authority, which is
|
||||
* irreversible from our side. It therefore needs its own deliberate switch, separate from
|
||||
* `EIMS_ENABLED`, so that authentication can be live long before filing is.
|
||||
*/
|
||||
autoSubmit: boolean;
|
||||
autoSubmitCron: string;
|
||||
/** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */
|
||||
autoSubmitMaxAgeDays: number;
|
||||
/**
|
||||
* Seller identity and tax/business treatment for the invoice document.
|
||||
*
|
||||
* None of this is derivable from the database: EDR's own legal identity exists nowhere in the
|
||||
* codebase, and the app models no tax at all. Values are required at registration time and are
|
||||
* validated there rather than at boot, so a deployment can run with EIMS enabled for
|
||||
* authentication before finance has signed off on the tax treatment.
|
||||
*/
|
||||
invoice: EimsInvoiceConfig;
|
||||
}
|
||||
|
||||
export interface EimsInvoiceConfig {
|
||||
sellerLegalName: string;
|
||||
sellerVatNumber: string;
|
||||
sellerPhone: string;
|
||||
sellerEmail: string;
|
||||
/** MoR *codes*, not names (e.g. "13" for Addis Ababa, "574"). */
|
||||
sellerRegion: string;
|
||||
sellerWereda: string;
|
||||
sellerCity: string | null;
|
||||
sellerSubCity: string | null;
|
||||
sellerHouseNumber: string | null;
|
||||
sellerLocality: string | null;
|
||||
/** REQUIRES_BUSINESS_CONFIRMATION — no tax model exists in this application. */
|
||||
taxCode: string;
|
||||
taxRatePercent: number | null;
|
||||
exciseTaxValue: number | null;
|
||||
incomeWithholdValue: number | null;
|
||||
transactionWithholdValue: number | null;
|
||||
/** B2B / B2C — a tax classification, so it is configured, not inferred. */
|
||||
transactionType: string;
|
||||
natureOfSupplies: string;
|
||||
paymentMode: string;
|
||||
paymentTerm: string;
|
||||
unitDefault: string;
|
||||
buyerCountryCode: string | null;
|
||||
cashierName: string | null;
|
||||
salesPersonName: string | null;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
"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;
|
||||
};
|
||||
|
||||
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export default registerAs("eims", (): EimsConfig => {
|
||||
const enabled = (process.env.EIMS_ENABLED ?? "false").toLowerCase() === "true";
|
||||
const baseUrl = (process.env.EIMS_BASE_URL ?? "https://core.mor.gov.et").replace(/\/+$/, "");
|
||||
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,
|
||||
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
||||
// Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a
|
||||
// misconfiguration from filing a burst of bad documents before anyone notices.
|
||||
autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *",
|
||||
autoSubmitMaxAgeDays: positiveInt(
|
||||
process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS,
|
||||
3,
|
||||
"EIMS_AUTO_SUBMIT_MAX_AGE_DAYS",
|
||||
),
|
||||
invoice: {
|
||||
sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "",
|
||||
sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "",
|
||||
sellerPhone: process.env.EIMS_SELLER_PHONE ?? "",
|
||||
sellerEmail: process.env.EIMS_SELLER_EMAIL ?? "",
|
||||
sellerRegion: process.env.EIMS_SELLER_REGION ?? "",
|
||||
sellerWereda: process.env.EIMS_SELLER_WEREDA ?? "",
|
||||
sellerCity: process.env.EIMS_SELLER_CITY || null,
|
||||
sellerSubCity: process.env.EIMS_SELLER_SUBCITY || null,
|
||||
sellerHouseNumber: process.env.EIMS_SELLER_HOUSE_NUMBER || null,
|
||||
sellerLocality: process.env.EIMS_SELLER_LOCALITY || null,
|
||||
taxCode: process.env.EIMS_TAX_CODE ?? "",
|
||||
taxRatePercent: optionalNumber(process.env.EIMS_TAX_RATE_PERCENT, "EIMS_TAX_RATE_PERCENT"),
|
||||
exciseTaxValue: optionalNumber(process.env.EIMS_EXCISE_TAX_VALUE, "EIMS_EXCISE_TAX_VALUE"),
|
||||
incomeWithholdValue: optionalNumber(
|
||||
process.env.EIMS_INCOME_WITHHOLD_VALUE,
|
||||
"EIMS_INCOME_WITHHOLD_VALUE",
|
||||
),
|
||||
transactionWithholdValue: optionalNumber(
|
||||
process.env.EIMS_TRANSACTION_WITHHOLD_VALUE,
|
||||
"EIMS_TRANSACTION_WITHHOLD_VALUE",
|
||||
),
|
||||
transactionType: process.env.EIMS_TRANSACTION_TYPE ?? "",
|
||||
natureOfSupplies: process.env.EIMS_NATURE_OF_SUPPLIES ?? "",
|
||||
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||
},
|
||||
};
|
||||
|
||||
if (!enabled) return base;
|
||||
|
||||
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,73 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* EIMS registration state.
|
||||
*
|
||||
* `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice
|
||||
* consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the
|
||||
* database-level guarantee that one IRN can never be recorded against two invoices, independent of
|
||||
* application logic.
|
||||
*
|
||||
* `freight.eims_system_state` is a single row per MoR system number holding the sequence the
|
||||
* gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn`
|
||||
* of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter
|
||||
* and the IRN chain stay consistent under concurrent submissions.
|
||||
*
|
||||
* The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and
|
||||
* the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves
|
||||
* evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set
|
||||
* when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later
|
||||
* document for this system number would chain to a stale `PreviousIrn` and registration stops until
|
||||
* a human resolves it.
|
||||
*
|
||||
* `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string
|
||||
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
|
||||
* verbatim so a compliance value is never mangled by a parse.
|
||||
*/
|
||||
export class EimsInvoiceRegistration3300000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
|
||||
ADD COLUMN IF NOT EXISTS eims_irn varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint,
|
||||
ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS eims_last_error jsonb
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn
|
||||
ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.eims_system_state (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
system_number varchar(32) NOT NULL UNIQUE,
|
||||
next_invoice_counter bigint NOT NULL DEFAULT 1,
|
||||
previous_irn varchar(64),
|
||||
in_flight_invoice_id uuid,
|
||||
in_flight_counter bigint,
|
||||
blocked_reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS eims_status,
|
||||
DROP COLUMN IF EXISTS eims_irn,
|
||||
DROP COLUMN IF EXISTS eims_invoice_counter,
|
||||
DROP COLUMN IF EXISTS eims_submitted_at,
|
||||
DROP COLUMN IF EXISTS eims_ack_date,
|
||||
DROP COLUMN IF EXISTS eims_last_error
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
||||
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||
import { Company } from "../../companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||
@@ -105,4 +106,27 @@ export class Invoice extends BaseEntity {
|
||||
|
||||
@Column({ name: "due_at", type: "timestamptz" })
|
||||
dueAt!: Date;
|
||||
|
||||
/** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */
|
||||
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||
eimsStatus!: EimsInvoiceStatus;
|
||||
|
||||
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
|
||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
||||
eimsIrn?: string | null;
|
||||
|
||||
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
|
||||
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
||||
eimsInvoiceCounter?: number | null;
|
||||
|
||||
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
|
||||
eimsSubmittedAt?: Date | null;
|
||||
|
||||
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
|
||||
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
|
||||
eimsAckDate?: string | null;
|
||||
|
||||
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
||||
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
||||
eimsLastError?: EimsInvoiceError | null;
|
||||
}
|
||||
|
||||
@@ -424,7 +424,10 @@ export class BookingsService {
|
||||
|
||||
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
||||
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
||||
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
|
||||
// Container bookings carry no cargo type or free text — name the freight type
|
||||
// rather than printing a dash in the Cargo Name column.
|
||||
const cargoName =
|
||||
booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-';
|
||||
const currency = booking.paymentCurrency ?? 'ETB';
|
||||
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
||||
const prices = this.splitAmountAcrossWagons(
|
||||
@@ -467,6 +470,28 @@ export class BookingsService {
|
||||
)
|
||||
.join('');
|
||||
|
||||
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
|
||||
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
|
||||
// figure from the printed sheet.
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td></td>
|
||||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -490,7 +515,7 @@ export class BookingsService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tfoot td { background: #f8fafc; font-weight: 700; }
|
||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
@@ -538,21 +563,8 @@ export class BookingsService {
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
${totalsRow}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">${
|
||||
pendingWagons
|
||||
? `Received lines: ${wagons.length} — wagons pending marshalling`
|
||||
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
/**
|
||||
* Manual reconciliation of a submission that was never acknowledged. Exactly one of the two is
|
||||
* meaningful: supply the IRN confirmed with MoR, or discard the attempt.
|
||||
*/
|
||||
export class ResolveEimsRegistrationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
||||
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
irn?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Abandon the submission: the invoice is marked FAILED and the chain is unchanged.",
|
||||
example: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
discard?: boolean;
|
||||
}
|
||||
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
256
apps/edr-freight-api/src/modules/eims/eims-auth.service.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { of, throwError } from "rxjs";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { eimsConfig, eimsToken } from "./eims-test-fixtures";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
|
||||
const CLIENT_SECRET = "super-secret-value";
|
||||
const API_KEY = "super-secret-apikey";
|
||||
|
||||
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => eimsConfig(over);
|
||||
|
||||
const TOKEN_1 = eimsToken({ jti: "one" });
|
||||
const TOKEN_2 = eimsToken({ jti: "two" });
|
||||
|
||||
const loginBody = (accessToken: string, expiresIn = 3600) => ({
|
||||
data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn },
|
||||
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("does not put the access token in its own log line", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
const logged: string[] = [];
|
||||
const auth = build(post);
|
||||
jest
|
||||
.spyOn(auth["logger"], "log")
|
||||
.mockImplementation((message: unknown) => void logged.push(String(message)));
|
||||
|
||||
await auth.getValidAccessToken();
|
||||
|
||||
expect(logged.join("\n")).not.toContain(TOKEN_1);
|
||||
expect(logged.join("\n")).toContain("B0360154BA");
|
||||
});
|
||||
|
||||
it("refuses to call the gateway when EIMS is disabled", async () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsAuthService.getSessionContext", () => {
|
||||
it("takes the source system from the token's claims", async () => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }),
|
||||
);
|
||||
|
||||
// Env deliberately left empty: with nothing to check against, the token is simply believed.
|
||||
await expect(
|
||||
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
|
||||
).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" });
|
||||
});
|
||||
|
||||
it("serves the session from the cached login rather than re-authenticating", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
const auth = build(post);
|
||||
|
||||
await auth.getSessionContext();
|
||||
await expect(auth.getSessionContext()).resolves.toEqual({
|
||||
systemNumber: "B0360154BA",
|
||||
systemType: "SYS",
|
||||
});
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => {
|
||||
const post = jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) }));
|
||||
|
||||
await expect(
|
||||
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
|
||||
).rejects.toThrow(new RegExp(`no ${claim} claim`));
|
||||
});
|
||||
|
||||
it("rejects an access token that is not a decodable JWT", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") }));
|
||||
|
||||
await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/],
|
||||
["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/],
|
||||
])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) }));
|
||||
|
||||
// cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations.
|
||||
await expect(build(post).getSessionContext()).rejects.toThrow(pattern);
|
||||
});
|
||||
|
||||
it("accepts a configured value that matches the token", async () => {
|
||||
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
|
||||
|
||||
await expect(build(post).getSessionContext()).resolves.toEqual({
|
||||
systemNumber: "B0360154BA",
|
||||
systemType: "SYS",
|
||||
});
|
||||
});
|
||||
});
|
||||
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
212
apps/edr-freight-api/src/modules/eims/eims-auth.service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
session: EimsSessionContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-system identity, taken from the access token MoR issues us.
|
||||
*
|
||||
* The gateway stamps `systemNumber` and `systemType` into the token for the credentials that
|
||||
* authenticated, which makes the token the authority on them — not our environment file. Anything
|
||||
* we configured locally can only ever disagree with what MoR believes.
|
||||
*/
|
||||
export interface EimsSessionContext {
|
||||
systemNumber: string;
|
||||
systemType: string;
|
||||
}
|
||||
|
||||
/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */
|
||||
function decodeTokenClaims(accessToken: string): Record<string, unknown> {
|
||||
const payload = accessToken.split(".")[1];
|
||||
if (!payload) {
|
||||
throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
// The token itself is never included — only that its payload would not parse.
|
||||
throw new EimsApiException(
|
||||
"UNKNOWN",
|
||||
`EIMS access token payload could not be decoded: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const claimString = (claims: Record<string, unknown>, name: string): string => {
|
||||
const value = claims[name];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
};
|
||||
|
||||
/** Used when the gateway omits `expiresIn`; the observed value is 3600. */
|
||||
const FALLBACK_EXPIRES_IN_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source-system identity MoR issued this session, refreshing the login if needed.
|
||||
*
|
||||
* This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both
|
||||
* into the access token for the authenticating credentials, so a local env value could only ever
|
||||
* disagree with it.
|
||||
*/
|
||||
async getSessionContext(): Promise<EimsSessionContext> {
|
||||
await this.getValidAccessToken();
|
||||
return this.cache!.session;
|
||||
}
|
||||
|
||||
/** Drop the cached token — called after a 401 so the next request re-authenticates. */
|
||||
invalidate(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the source-system claims out of the token, and cross-check anything configured locally.
|
||||
*
|
||||
* `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they
|
||||
* are compared and a mismatch fails immediately rather than one silently winning. Registering
|
||||
* under the wrong source system is not something to discover from a rejected invoice.
|
||||
*/
|
||||
private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext {
|
||||
const claims = decodeTokenClaims(accessToken);
|
||||
const systemNumber = claimString(claims, "systemNumber");
|
||||
const systemType = claimString(claims, "systemType");
|
||||
|
||||
const missing = [
|
||||
!systemNumber && "systemNumber",
|
||||
!systemType && "systemType",
|
||||
].filter(Boolean);
|
||||
if (missing.length > 0) {
|
||||
throw new EimsApiException(
|
||||
"UNKNOWN",
|
||||
`EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`,
|
||||
);
|
||||
}
|
||||
|
||||
const mismatches = [
|
||||
cfg.systemNumber && cfg.systemNumber !== systemNumber
|
||||
? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}`
|
||||
: null,
|
||||
cfg.systemType && cfg.systemType !== systemType
|
||||
? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
if (mismatches.length > 0) {
|
||||
throw new EimsConfigException(
|
||||
`EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` +
|
||||
"Correct the environment or the credentials — neither value is assumed to win.",
|
||||
);
|
||||
}
|
||||
|
||||
return { systemNumber, systemType };
|
||||
}
|
||||
|
||||
private async login(): Promise<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.
|
||||
// Reject the session before caching it: a token we cannot identify a source system from is
|
||||
// useless for registration, and a configured expectation that disagrees is a deployment fault.
|
||||
const session = this.readSessionContext(accessToken, cfg);
|
||||
|
||||
this.cache = {
|
||||
accessToken,
|
||||
expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000),
|
||||
session,
|
||||
};
|
||||
this.logger.log(
|
||||
`EIMS login succeeded; token cached for ~${expiresIn}s ` +
|
||||
`(system ${session.systemNumber}, type ${session.systemType})`,
|
||||
);
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
import { eimsConfig } from "./eims-test-fixtures";
|
||||
|
||||
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
/**
|
||||
* `query` is answered by shape: the first call is the system-state guard, the second is the
|
||||
* candidate lookup. Keeps the fake honest about the order the service actually asks in.
|
||||
*/
|
||||
const build = (
|
||||
opts: {
|
||||
cfg?: Partial<EimsConfig>;
|
||||
state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null };
|
||||
candidate?: { id: string; invoiceNumber: string } | null;
|
||||
register?: jest.Mock;
|
||||
} = {},
|
||||
) => {
|
||||
const register =
|
||||
opts.register ??
|
||||
jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" });
|
||||
|
||||
const query = jest.fn().mockImplementation((sql: string) => {
|
||||
if (sql.includes("eims_system_state")) {
|
||||
return Promise.resolve(
|
||||
opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [],
|
||||
);
|
||||
}
|
||||
return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []);
|
||||
});
|
||||
|
||||
const service = new EimsAutoSubmitService(
|
||||
{ query } as unknown as DataSource,
|
||||
{ get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService,
|
||||
{ registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService,
|
||||
);
|
||||
return { service, register, query };
|
||||
};
|
||||
|
||||
const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" };
|
||||
|
||||
describe("EimsAutoSubmitService.tick", () => {
|
||||
it("files the oldest eligible invoice through the registration service", async () => {
|
||||
const { service, register } = build({ candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
expect(register).toHaveBeenCalledWith(INVOICE_ID);
|
||||
});
|
||||
|
||||
it("files nothing when EIMS_AUTO_SUBMIT is off", async () => {
|
||||
const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => {
|
||||
const { service, register, query } = build({ cfg: { enabled: false }, candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit while another submission is in flight", async () => {
|
||||
const { service, register } = build({
|
||||
state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" },
|
||||
candidate,
|
||||
});
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit while the system number is blocked", async () => {
|
||||
const { service, register } = build({
|
||||
state: { blocked_reason: "never acknowledged" },
|
||||
candidate,
|
||||
});
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no invoice is eligible", async () => {
|
||||
const { service, register } = build({ candidate: null });
|
||||
|
||||
await service.tick();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => {
|
||||
const { service, query } = build({ candidate });
|
||||
|
||||
await service.tick();
|
||||
|
||||
const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!;
|
||||
expect(sql).toContain("i.eims_status = $1");
|
||||
expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||
expect(sql).toContain("i.issued_at IS NOT NULL");
|
||||
});
|
||||
|
||||
it("survives a filing failure so the job keeps running", async () => {
|
||||
const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)"));
|
||||
const { service } = build({ candidate, register });
|
||||
|
||||
await expect(service.tick()).resolves.toBeUndefined();
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not start a second tick while one is still filing", async () => {
|
||||
let release: () => void = () => {};
|
||||
const register = jest.fn().mockImplementation(
|
||||
() => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))),
|
||||
);
|
||||
const { service } = build({ candidate, register });
|
||||
|
||||
const first = service.tick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await service.tick(); // overlapping tick, must be a no-op
|
||||
|
||||
expect(register).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
|
||||
/**
|
||||
* Files issued invoices with MoR EIMS on a timer.
|
||||
*
|
||||
* Invoices are produced by the freight workflow rather than by a person, so this — not the manual
|
||||
* endpoint — is the production path. It is a sweep rather than a hook on the eleven places an
|
||||
* invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP
|
||||
* call is by construction outside the invoice's transaction, and an invoice missed through a crash
|
||||
* or a restart is picked up on the next tick.
|
||||
*
|
||||
* `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible:
|
||||
* `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED`
|
||||
* waits for an explicit retry policy rather than a timer's guess.
|
||||
*
|
||||
* Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing
|
||||
* real documents with the tax authority, and a registration cannot be undone from this side.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsAutoSubmitService {
|
||||
private readonly logger = new Logger(EimsAutoSubmitService.name);
|
||||
/** Guards against a tick starting while the previous one is still filing. */
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly registration: EimsInvoiceRegistrationService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
/**
|
||||
* One invoice per tick.
|
||||
*
|
||||
* Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an
|
||||
* ambiguous result blocks the system number until a human resolves it, and a misconfiguration
|
||||
* should cost one rejected document rather than a burst of them.
|
||||
*/
|
||||
@Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" })
|
||||
async tick(): Promise<void> {
|
||||
const cfg = this.cfg;
|
||||
if (!cfg.enabled || !cfg.autoSubmit) return;
|
||||
if (this.running) return;
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
// Rule of the chain: nothing may be filed while a submission is in flight or the system is
|
||||
// blocked. The reservation would refuse anyway — checking first keeps the log quiet and
|
||||
// avoids burning a tick on a guaranteed conflict.
|
||||
const blocked = await this.systemBlockReason();
|
||||
if (blocked) {
|
||||
this.logger.warn(`EIMS auto-submit paused: ${blocked}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = await this.nextCandidate();
|
||||
if (!candidate) return;
|
||||
|
||||
const view = await this.registration.registerInvoiceWithEims(candidate.id);
|
||||
this.logger.log(
|
||||
`EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` +
|
||||
(view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""),
|
||||
);
|
||||
} catch (err) {
|
||||
// Never let a filing failure kill the job. The outcome is already persisted on the invoice
|
||||
// (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the
|
||||
// next tick at the guard above.
|
||||
this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why filing is currently impossible for this system number, or null when it is free. */
|
||||
private async systemBlockReason(): Promise<string | null> {
|
||||
const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT in_flight_invoice_id, blocked_reason
|
||||
FROM freight.eims_system_state
|
||||
WHERE system_number = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[this.cfg.systemNumber],
|
||||
);
|
||||
const state = rows[0];
|
||||
if (!state) return null;
|
||||
if (state.blocked_reason) return state.blocked_reason;
|
||||
if (state.in_flight_invoice_id) {
|
||||
return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and
|
||||
* carries at least one line.
|
||||
*/
|
||||
private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> {
|
||||
const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query(
|
||||
`SELECT i.id, i.invoice_number AS "invoiceNumber"
|
||||
FROM freight.invoices i
|
||||
WHERE i.eims_status = $1
|
||||
AND i.issued_at IS NOT NULL
|
||||
AND i.deleted_at IS NULL
|
||||
AND i.issued_at > now() - ($2 || ' days')::interval
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.invoice_lines l
|
||||
WHERE l.invoice_id = i.id AND l.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY i.issued_at ASC
|
||||
LIMIT 1`,
|
||||
[EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
80
apps/edr-freight-api/src/modules/eims/eims-client.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
|
||||
*
|
||||
* `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a
|
||||
* raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point
|
||||
* so that if the live gateway turns out to require signing after all, exactly one call site
|
||||
* changes — `postSigned` is already the alternative.
|
||||
*/
|
||||
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||
}
|
||||
|
||||
private async send<TRequest, TResponse>(
|
||||
path: string,
|
||||
request: TRequest,
|
||||
isRetry: boolean,
|
||||
signed: boolean,
|
||||
): Promise<TResponse> {
|
||||
const cfg = this.cfg;
|
||||
const token = await this.auth.getValidAccessToken();
|
||||
const body = signed ? toSignedBody(this.signer.signRequest(request)) : 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, signed);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
117
apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts
Normal file
117
apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsSessionContext } from "./eims-auth.service";
|
||||
import {
|
||||
EimsMapperContext,
|
||||
EimsMapperLine,
|
||||
EimsSellerDetails,
|
||||
} from "../billing/eims-invoice.mapper";
|
||||
|
||||
/**
|
||||
* Turns configuration into the seller identity and mapper context that `toEimsInvoice` requires.
|
||||
*
|
||||
* Everything here is unavailable from the database by construction: EDR's own legal identity is not
|
||||
* modelled anywhere, and the application has no tax model at all (`invoice.taxAmount` is always 0,
|
||||
* `invoice_lines` and the rate catalogue carry no fiscal columns). Rather than defaulting any of it,
|
||||
* a missing value fails **here** — locally, before a single byte reaches the gateway — naming the
|
||||
* exact environment variables to set.
|
||||
*/
|
||||
|
||||
interface RequiredSpec {
|
||||
env: string;
|
||||
value: string | number | null | undefined;
|
||||
}
|
||||
|
||||
// `systemNumber` / `systemType` are absent by design: they come from the access token, which is
|
||||
// MoR's own statement of who we are. See EimsAuthService.getSessionContext.
|
||||
const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [
|
||||
{ env: "EIMS_TIN", value: tin },
|
||||
{ env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName },
|
||||
{ env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber },
|
||||
{ env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone },
|
||||
{ env: "EIMS_SELLER_EMAIL", value: invoice.sellerEmail },
|
||||
{ env: "EIMS_SELLER_REGION", value: invoice.sellerRegion },
|
||||
{ env: "EIMS_SELLER_WEREDA", value: invoice.sellerWereda },
|
||||
{ env: "EIMS_TAX_CODE", value: invoice.taxCode },
|
||||
{ env: "EIMS_TAX_RATE_PERCENT", value: invoice.taxRatePercent },
|
||||
{ env: "EIMS_INCOME_WITHHOLD_VALUE", value: invoice.incomeWithholdValue },
|
||||
{ env: "EIMS_TRANSACTION_WITHHOLD_VALUE", value: invoice.transactionWithholdValue },
|
||||
{ env: "EIMS_TRANSACTION_TYPE", value: invoice.transactionType },
|
||||
{ env: "EIMS_NATURE_OF_SUPPLIES", value: invoice.natureOfSupplies },
|
||||
{ env: "EIMS_PAYMENT_MODE", value: invoice.paymentMode },
|
||||
{ env: "EIMS_PAYMENT_TERM", value: invoice.paymentTerm },
|
||||
{ env: "EIMS_UNIT_DEFAULT", value: invoice.unitDefault },
|
||||
];
|
||||
|
||||
/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */
|
||||
export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
||||
const missing = REQUIRED(config.invoice, config.tin)
|
||||
.filter(({ value }) => value === null || value === undefined || value === "")
|
||||
.map(({ env }) => env);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_INVOICE_CONFIG_INCOMPLETE",
|
||||
message:
|
||||
"EIMS invoice registration is not configured. Set these environment variables " +
|
||||
`(tax values need finance sign-off — they are deliberately not defaulted): ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
|
||||
const { invoice } = config;
|
||||
return {
|
||||
City: invoice.sellerCity,
|
||||
Email: invoice.sellerEmail,
|
||||
HouseNumber: invoice.sellerHouseNumber,
|
||||
LegalName: invoice.sellerLegalName,
|
||||
Locality: invoice.sellerLocality,
|
||||
Phone: invoice.sellerPhone,
|
||||
Region: invoice.sellerRegion,
|
||||
SubCity: invoice.sellerSubCity,
|
||||
Tin: config.tin,
|
||||
VatNumber: invoice.sellerVatNumber,
|
||||
Wereda: invoice.sellerWereda,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EimsContextInput {
|
||||
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
|
||||
documentNumber: string;
|
||||
invoiceCounter: number;
|
||||
previousIrn: string | null;
|
||||
/** Source-system identity from the access token, never from configuration. */
|
||||
session: EimsSessionContext;
|
||||
/** Required when the invoice currency is not ETB. */
|
||||
exchangeRate?: number | null;
|
||||
}
|
||||
|
||||
export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext {
|
||||
const { invoice } = config;
|
||||
// Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call.
|
||||
const taxCode = invoice.taxCode;
|
||||
const ratePercent = invoice.taxRatePercent!;
|
||||
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
|
||||
|
||||
return {
|
||||
systemNumber: input.session.systemNumber,
|
||||
systemType: input.session.systemType,
|
||||
documentNumber: input.documentNumber,
|
||||
invoiceCounter: input.invoiceCounter,
|
||||
previousIrn: input.previousIrn,
|
||||
cashierName: invoice.cashierName,
|
||||
salesPersonName: invoice.salesPersonName,
|
||||
transactionType: invoice.transactionType,
|
||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||
// One treatment for every line today. The mapper resolves tax per line, so a future
|
||||
// charge-type-specific rule slots in here without touching the mapper.
|
||||
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
|
||||
natureOfSupplies: invoice.natureOfSupplies,
|
||||
unitDefault: invoice.unitDefault,
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
exchangeRate: input.exchangeRate ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||
|
||||
const SYSTEM_NUMBER = "B0360154BA";
|
||||
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||
|
||||
const config = (over: Partial<EimsConfig["invoice"]> = {}): EimsConfig =>
|
||||
({
|
||||
enabled: true,
|
||||
baseUrl: "https://core.mor.gov.et",
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
apiKey: "key",
|
||||
tin: "0000034558",
|
||||
systemNumber: SYSTEM_NUMBER,
|
||||
systemType: "SYS",
|
||||
privateKeyPath: "/dev/null",
|
||||
certificatePath: "/dev/null",
|
||||
httpTimeoutMs: 30_000,
|
||||
tokenSkewMs: 45_000,
|
||||
invoice: eimsInvoiceConfig(over),
|
||||
}) as EimsConfig;
|
||||
|
||||
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
id: INVOICE_ID,
|
||||
invoiceNumber: "INV-20260807-00042",
|
||||
currency: "ETB",
|
||||
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
|
||||
totalAmount: "10000.00",
|
||||
eimsStatus: EimsInvoiceStatus.NotSubmitted,
|
||||
eimsIrn: null,
|
||||
eimsInvoiceCounter: null,
|
||||
eimsSubmittedAt: null,
|
||||
eimsAckDate: null,
|
||||
eimsLastError: null,
|
||||
company: {
|
||||
name: "ABC Trading PLC",
|
||||
tin: "0999930000",
|
||||
vatNumber: "123475885858",
|
||||
phone: "0912345678",
|
||||
email: "buyer@abc.et",
|
||||
region: "13",
|
||||
zone: "SHA",
|
||||
woreda: "574",
|
||||
kebele: "03",
|
||||
houseNo: "NEW",
|
||||
country: "Ethiopia",
|
||||
},
|
||||
...over,
|
||||
}) as unknown as Invoice;
|
||||
|
||||
const LINES = [
|
||||
{
|
||||
chargeType: "RAIL_FREIGHT",
|
||||
description: "Addis to Djibouti",
|
||||
quantity: "1.00",
|
||||
unitRate: "10000.00",
|
||||
amount: "10000.00",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()`
|
||||
* returns the live object — enough to assert ordering, values and the reservation lifecycle without
|
||||
* a database.
|
||||
*/
|
||||
class FakeDb {
|
||||
invoices = new Map<string, Invoice>();
|
||||
state: EimsSystemState | null = null;
|
||||
/** Runs before every transaction body, to simulate a concurrent writer. */
|
||||
onTransaction: (() => void) | null = null;
|
||||
|
||||
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||
this.state = {
|
||||
id: "state-1",
|
||||
systemNumber: SYSTEM_NUMBER,
|
||||
nextInvoiceCounter: 7,
|
||||
previousIrn: null,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
blockedReason: null,
|
||||
...state,
|
||||
} as EimsSystemState;
|
||||
}
|
||||
|
||||
private manager = {
|
||||
createQueryBuilder: (entity: unknown) => {
|
||||
const isInvoice = entity === Invoice;
|
||||
let id: string | undefined;
|
||||
const builder = {
|
||||
setLock: () => builder,
|
||||
where: (_clause: string, params: Record<string, string>) => {
|
||||
id = params.invoiceId ?? params.systemNumber;
|
||||
return builder;
|
||||
},
|
||||
getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state),
|
||||
};
|
||||
return builder;
|
||||
},
|
||||
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
|
||||
this.invoices.get(options.where.id) ?? null,
|
||||
update: async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||
if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch);
|
||||
else Object.assign(this.state!, patch);
|
||||
},
|
||||
query: async () => [],
|
||||
getRepository: () => ({
|
||||
findOne: async (options: { where: { id: string } }) =>
|
||||
this.invoices.get(options.where.id) ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
asDataSource(): DataSource {
|
||||
return {
|
||||
manager: this.manager,
|
||||
getRepository: this.manager.getRepository,
|
||||
query: async () => LINES,
|
||||
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||
this.onTransaction?.();
|
||||
return body(this.manager);
|
||||
},
|
||||
} as unknown as DataSource;
|
||||
}
|
||||
}
|
||||
|
||||
/** The source system comes from the access token, so the service is handed a session, not config. */
|
||||
const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" };
|
||||
|
||||
const build = (
|
||||
db: FakeDb,
|
||||
postSigned: jest.Mock,
|
||||
cfg: EimsConfig = config(),
|
||||
postBearer: jest.Mock = jest.fn(),
|
||||
getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION),
|
||||
) =>
|
||||
new EimsInvoiceRegistrationService(
|
||||
db.asDataSource(),
|
||||
{ get: () => cfg } as unknown as ConfigService,
|
||||
{ postSigned, postBearer } as unknown as EimsClientService,
|
||||
{ getSessionContext } as unknown as EimsAuthService,
|
||||
);
|
||||
|
||||
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
|
||||
const DOCUMENT_NUMBER = "INV-20260807-00042";
|
||||
|
||||
/**
|
||||
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
|
||||
* `irn`.
|
||||
*
|
||||
* The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection
|
||||
* pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of
|
||||
* the mock rather than gateway behaviour; asserting against that inconsistency would encode the
|
||||
* mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for,
|
||||
* and these fixtures exercise that honestly.
|
||||
*/
|
||||
const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
||||
statusCode: 200,
|
||||
message: "SUCCESS",
|
||||
body: {
|
||||
Irn: IRN,
|
||||
TransactionType: "B2B",
|
||||
DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" },
|
||||
Version: "1",
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
const okResponse = (irn = IRN) =>
|
||||
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
||||
|
||||
const apiError = (kind: string, status?: number) =>
|
||||
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
||||
|
||||
describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
it("registers, persists the IRN and advances the chain", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||
expect(postSigned.mock.calls[0][0]).toBe("/v1/register");
|
||||
expect(view).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: IRN,
|
||||
eimsInvoiceCounter: 7,
|
||||
eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]",
|
||||
});
|
||||
expect(db.state).toMatchObject({
|
||||
previousIrn: IRN,
|
||||
nextInvoiceCounter: 8,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||
expect(request.SourceSystem.InvoiceCounter).toBe(42);
|
||||
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
|
||||
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
|
||||
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
|
||||
});
|
||||
|
||||
it("takes SourceSystem from the token session, not from configuration", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
// Config disagrees on purpose: only the session may reach the wire.
|
||||
const cfg = config();
|
||||
(cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY";
|
||||
(cfg as { systemType: string }).systemType = "MAN";
|
||||
|
||||
await build(
|
||||
db,
|
||||
postSigned,
|
||||
cfg,
|
||||
jest.fn(),
|
||||
jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }),
|
||||
).registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||
expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN");
|
||||
expect(request.SourceSystem.SystemType).toBe("POS");
|
||||
});
|
||||
|
||||
it("does not consume a counter when authentication fails", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn();
|
||||
const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed"));
|
||||
|
||||
await expect(
|
||||
build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
),
|
||||
).rejects.toThrow(/login failed/);
|
||||
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
|
||||
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||
});
|
||||
|
||||
it("is idempotent — an invoice with an IRN never reaches EIMS", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }),
|
||||
]);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(view.eimsIrn).toBe(IRN);
|
||||
});
|
||||
|
||||
it("lets only one of two concurrent calls reach EIMS", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
let resolvePost: (v: unknown) => void = () => {};
|
||||
const postSigned = jest
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => (resolvePost = resolve)));
|
||||
const service = build(db, postSigned);
|
||||
|
||||
const first = service.registerInvoiceWithEims(INVOICE_ID);
|
||||
// Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = service.registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
await expect(second).rejects.toBeInstanceOf(ConflictException);
|
||||
resolvePost(okResponse());
|
||||
await first;
|
||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks a different invoice while a submission is in flight (survives a restart)", async () => {
|
||||
// A committed reservation left behind by a dead process.
|
||||
const db = new FakeDb(
|
||||
[
|
||||
invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }),
|
||||
invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }),
|
||||
],
|
||||
{ inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 },
|
||||
);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
await expect(
|
||||
build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID),
|
||||
).rejects.toThrow(/already in flight/);
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
await expect(
|
||||
build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
|
||||
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["SCHEMA_VALIDATION", 400],
|
||||
["RULE_VALIDATION", 406],
|
||||
])("marks %s (%i) FAILED and clears the global block", async (kind, status) => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockRejectedValue(apiError(kind, status));
|
||||
|
||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||
EimsApiException,
|
||||
);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Failed,
|
||||
eimsIrn: null,
|
||||
});
|
||||
expect(db.state).toMatchObject({
|
||||
inFlightInvoiceId: null,
|
||||
blockedReason: null,
|
||||
previousIrn: null,
|
||||
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a success response with no IRN as a failed registration", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
||||
|
||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||
/returned no IRN/,
|
||||
);
|
||||
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
|
||||
expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null });
|
||||
});
|
||||
|
||||
it("marks a timeout UNKNOWN and keeps the system blocked", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
|
||||
|
||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||
EimsApiException,
|
||||
);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||
eimsIrn: null,
|
||||
});
|
||||
expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID);
|
||||
expect(db.state!.blockedReason).toMatch(/never acknowledged/);
|
||||
expect(db.state!.previousIrn).toBeNull();
|
||||
});
|
||||
|
||||
it("an UNKNOWN result blocks a different invoice too", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||
const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT"));
|
||||
const service = build(db, postSigned);
|
||||
|
||||
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||
EimsApiException,
|
||||
);
|
||||
await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow(
|
||||
/registration is blocked/,
|
||||
);
|
||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never reuses a counter once an attempt has begun", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||
const postSigned = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(apiError("RULE_VALIDATION", 406))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
const service = build(db, postSigned);
|
||||
|
||||
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||
EimsApiException,
|
||||
);
|
||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||
|
||||
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
||||
it("verifies the stored IRN over the unsigned bearer transport", async () => {
|
||||
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
||||
const postSigned = jest.fn();
|
||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||
|
||||
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
);
|
||||
|
||||
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
|
||||
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(result.body).toMatchObject({ Irn: IRN });
|
||||
});
|
||||
|
||||
it("rejects a 200 that carries no Irn", async () => {
|
||||
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
||||
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
||||
).rejects.toThrow(/returned no Irn/);
|
||||
});
|
||||
|
||||
it("refuses to verify an invoice with no IRN", async () => {
|
||||
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
|
||||
const postBearer = jest.fn();
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
|
||||
).rejects.toThrow(/no EIMS IRN to verify/);
|
||||
expect(postBearer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
||||
const blocked = () =>
|
||||
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
|
||||
inFlightInvoiceId: INVOICE_ID,
|
||||
inFlightCounter: 7,
|
||||
nextInvoiceCounter: 8,
|
||||
blockedReason: "never acknowledged",
|
||||
});
|
||||
|
||||
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||
|
||||
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
||||
INVOICE_ID,
|
||||
{ irn: IRN },
|
||||
);
|
||||
|
||||
// The IRN is confirmed at the gateway before it is ever written.
|
||||
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
||||
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
|
||||
expect(db.state).toMatchObject({
|
||||
previousIrn: IRN,
|
||||
inFlightInvoiceId: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest
|
||||
.fn()
|
||||
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||
).rejects.toThrow(/answered the lookup for IRN/);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||
eimsIrn: null,
|
||||
});
|
||||
expect(db.state).toMatchObject({
|
||||
inFlightInvoiceId: INVOICE_ID,
|
||||
blockedReason: "never acknowledged",
|
||||
previousIrn: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest.fn().mockResolvedValue(
|
||||
verifyResponse({
|
||||
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||
).rejects.toThrow(/not INV-20260807-00042/);
|
||||
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Unknown,
|
||||
eimsIrn: null,
|
||||
});
|
||||
expect(db.state).toMatchObject({
|
||||
inFlightInvoiceId: INVOICE_ID,
|
||||
blockedReason: "never acknowledged",
|
||||
previousIrn: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an IRN the gateway does not acknowledge at all", async () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
||||
).rejects.toThrow(/returned no Irn/);
|
||||
expect(db.state!.blockedReason).toBe("never acknowledged");
|
||||
});
|
||||
|
||||
it("discards the attempt, leaving the chain where it was", async () => {
|
||||
const db = blocked();
|
||||
const postBearer = jest.fn();
|
||||
|
||||
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
|
||||
INVOICE_ID,
|
||||
{ discard: true },
|
||||
);
|
||||
|
||||
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
|
||||
expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm
|
||||
expect(db.state).toMatchObject({
|
||||
previousIrn: null,
|
||||
inFlightInvoiceId: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to resolve an invoice that is not the in-flight one", async () => {
|
||||
const db = blocked();
|
||||
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
|
||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
||||
|
||||
await expect(
|
||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
||||
irn: IRN,
|
||||
}),
|
||||
).rejects.toThrow(/in-flight EIMS submission is invoice/);
|
||||
});
|
||||
|
||||
it("requires either an IRN or an explicit discard", async () => {
|
||||
await expect(
|
||||
build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
||||
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import {
|
||||
EimsInvoiceRequest,
|
||||
EimsMapperLine,
|
||||
toEimsInvoice,
|
||||
} from "../billing/eims-invoice.mapper";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
import {
|
||||
assertEimsInvoiceConfig,
|
||||
buildEimsContext,
|
||||
buildEimsSeller,
|
||||
} from "./eims-invoice-context";
|
||||
import {
|
||||
EimsInvoiceError,
|
||||
EimsInvoiceStatus,
|
||||
EimsInvoiceStatusView,
|
||||
EimsRegisterResponse,
|
||||
EimsVerifyRequest,
|
||||
EimsVerifyResponse,
|
||||
} from "./eims-registration.types";
|
||||
|
||||
/**
|
||||
* Failure kinds where the gateway gave a complete answer: the document was rejected and is
|
||||
* definitively not registered. These clear the system-wide block; anything else does not.
|
||||
*/
|
||||
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
||||
|
||||
interface Reservation {
|
||||
stateId: string;
|
||||
invoiceCounter: number;
|
||||
previousIrn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single invoice with MoR EIMS.
|
||||
*
|
||||
* Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a
|
||||
* committed transaction *before* the request leaves the process, and the network call happens
|
||||
* outside any transaction. That gives three properties the naive design could not:
|
||||
*
|
||||
* - a counter is never reused once an attempt has begun, even across a crash;
|
||||
* - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document
|
||||
* that may already have reached MoR;
|
||||
* - an ambiguous result blocks every invoice for the system number, not just its own, because
|
||||
* `PreviousIrn` is unknown and any later document would chain to a stale IRN.
|
||||
*
|
||||
* Signing, authentication and error normalisation belong to `EimsClientService`. Manual only —
|
||||
* nothing in invoice creation calls this.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsInvoiceRegistrationService {
|
||||
private readonly logger = new Logger(EimsInvoiceRegistrationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
return this.config.get<EimsConfig>("eims")!;
|
||||
}
|
||||
|
||||
async registerInvoiceWithEims(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||
const cfg = this.cfg;
|
||||
// Static seller/tax configuration is validated before anything is locked, allocated or sent.
|
||||
assertEimsInvoiceConfig(cfg);
|
||||
|
||||
const invoice = await this.loadInvoiceForMapping(invoiceId);
|
||||
if (invoice.eimsIrn) return this.toView(invoice);
|
||||
|
||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
||||
const session = await this.auth.getSessionContext();
|
||||
|
||||
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
||||
if (!reservation) return this.getEimsStatus(invoiceId);
|
||||
|
||||
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
|
||||
const request = toEimsInvoice(
|
||||
invoice,
|
||||
buildEimsSeller(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
// Our own invoice number is the document number; EIMS only requires it to be unique.
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
invoiceCounter: reservation.invoiceCounter,
|
||||
previousIrn: reservation.previousIrn,
|
||||
session,
|
||||
}),
|
||||
);
|
||||
|
||||
let irn: string;
|
||||
let ackDate: string | undefined;
|
||||
try {
|
||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||
const result = await this.submit(request);
|
||||
irn = result.irn;
|
||||
ackDate = result.ackDate;
|
||||
} catch (err) {
|
||||
await this.settleFailure(invoiceId, reservation, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
|
||||
this.logger.log(
|
||||
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
||||
);
|
||||
return this.getEimsStatus(invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a registered invoice at `POST /v1/verify`.
|
||||
*
|
||||
* Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled
|
||||
* here — the gateway offers no lookup by document number — so it must be resolved with MoR and
|
||||
* recorded through `resolveEimsRegistration`.
|
||||
*/
|
||||
async verifyInvoiceWithEims(invoiceId: string): Promise<EimsVerifyResponse> {
|
||||
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
||||
if (!invoice.eimsIrn) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_NO_IRN",
|
||||
message:
|
||||
`Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` +
|
||||
"EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.",
|
||||
});
|
||||
}
|
||||
return this.queryVerify(invoice.eimsIrn);
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo
|
||||
* an `Irn` back. A 200 without it is not a confirmation of anything.
|
||||
*
|
||||
* The request property is lowercase `irn`; the response spells it `Irn`. The two are never
|
||||
* compared — the supplied collection's own fixture uses different example values on each side,
|
||||
* so equality there would assert a property of the mock rather than of the gateway.
|
||||
*
|
||||
* Bearer-authenticated but unsigned, via `postBearer` — see that method for why.
|
||||
*/
|
||||
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
|
||||
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
|
||||
"/v1/verify",
|
||||
{ irn },
|
||||
);
|
||||
if (!response?.body?.Irn?.trim()) {
|
||||
throw new EimsApiException(
|
||||
"SCHEMA_VALIDATION",
|
||||
"EIMS verify returned no Irn in its response body",
|
||||
response?.statusCode,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
|
||||
* IRN is the one it holds, and that it belongs to this invoice.
|
||||
*
|
||||
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
|
||||
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
|
||||
*
|
||||
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
|
||||
* chains every later document to a stranger's reference, so both checks are refusals rather
|
||||
* than warnings.
|
||||
*/
|
||||
private async assertIrnBelongsToInvoice(
|
||||
irn: string,
|
||||
expectedDocumentNumber: string,
|
||||
): Promise<void> {
|
||||
const response = await this.queryVerify(irn);
|
||||
const returnedIrn = response.body?.Irn?.trim();
|
||||
const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim();
|
||||
|
||||
if (returnedIrn !== irn) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_RESOLVE_IRN_MISMATCH",
|
||||
message:
|
||||
`EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` +
|
||||
"Refusing to record it — recheck the IRN in the MoR portal.",
|
||||
});
|
||||
}
|
||||
|
||||
if (documentNumber !== expectedDocumentNumber) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_RESOLVE_DOCUMENT_MISMATCH",
|
||||
message:
|
||||
`EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` +
|
||||
`${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual reconciliation of a blocked system number.
|
||||
*
|
||||
* With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain
|
||||
* resumes from it. With `discard` the invoice is marked failed and the chain resumes from the
|
||||
* previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result.
|
||||
*
|
||||
* An IRN is never taken on trust: it is verified at the gateway first, and the document it
|
||||
* belongs to must be *this* invoice. A transposed digit would otherwise chain every later
|
||||
* document to a stranger's IRN and mark this invoice registered when it is not.
|
||||
*/
|
||||
async resolveEimsRegistration(
|
||||
invoiceId: string,
|
||||
input: { irn?: string; discard?: boolean },
|
||||
): Promise<EimsInvoiceStatusView> {
|
||||
const irn = input.irn?.trim();
|
||||
if (!irn && !input.discard) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_RESOLVE_INPUT_REQUIRED",
|
||||
message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission",
|
||||
});
|
||||
}
|
||||
|
||||
// Outside the transaction: no lock is held across the wire, and a refused verification must
|
||||
// leave the block exactly as it was.
|
||||
if (irn) {
|
||||
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
||||
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
|
||||
}
|
||||
|
||||
// Same source of truth as registration: the state row is keyed by the token's system number.
|
||||
const session = await this.auth.getSessionContext();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const state = await this.lockSystemState(manager, session.systemNumber);
|
||||
if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_RESOLVE_WRONG_INVOICE",
|
||||
message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`,
|
||||
});
|
||||
}
|
||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||
if (invoice.eimsIrn) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_ALREADY_REGISTERED",
|
||||
message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`,
|
||||
});
|
||||
}
|
||||
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed,
|
||||
eimsIrn: irn ?? null,
|
||||
});
|
||||
await manager.update(EimsSystemState, state.id, {
|
||||
// Only a confirmed IRN may advance the chain; a discard leaves it where it was.
|
||||
...(irn ? { previousIrn: irn } : {}),
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`,
|
||||
);
|
||||
return this.getEimsStatus(invoiceId);
|
||||
}
|
||||
|
||||
async getEimsStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||
return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId));
|
||||
}
|
||||
|
||||
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null`
|
||||
* when the invoice turned out to be registered already (checked under the lock).
|
||||
*/
|
||||
private async reserve(invoiceId: string, systemNumber: string): Promise<Reservation | null> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const state = await this.lockSystemState(manager, systemNumber);
|
||||
|
||||
if (state.blockedReason) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_SYSTEM_BLOCKED",
|
||||
message:
|
||||
`EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` +
|
||||
"Resolve the affected invoice before registering anything else.",
|
||||
});
|
||||
}
|
||||
if (state.inFlightInvoiceId) {
|
||||
throw new ConflictException({
|
||||
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
||||
message:
|
||||
`A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` +
|
||||
`${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||
if (invoice.eimsIrn) return null;
|
||||
|
||||
const invoiceCounter = Number(state.nextInvoiceCounter);
|
||||
const previousIrn = state.previousIrn ?? "";
|
||||
|
||||
// Counter consumed here, not on success: once an attempt begins it can never be reused,
|
||||
// whatever happens next. A gap is harmless at MoR; a collision is not.
|
||||
await manager.update(EimsSystemState, state.id, {
|
||||
nextInvoiceCounter: invoiceCounter + 1,
|
||||
inFlightInvoiceId: invoiceId,
|
||||
inFlightCounter: invoiceCounter,
|
||||
});
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: EimsInvoiceStatus.Submitting,
|
||||
eimsInvoiceCounter: invoiceCounter,
|
||||
eimsSubmittedAt: new Date(),
|
||||
eimsLastError: null,
|
||||
});
|
||||
|
||||
return { stateId: state.id, invoiceCounter, previousIrn };
|
||||
});
|
||||
}
|
||||
|
||||
/** TX2a. Record the IRN, advance the chain, release the reservation. */
|
||||
private async settleSuccess(
|
||||
invoiceId: string,
|
||||
reservation: Reservation,
|
||||
irn: string,
|
||||
ackDate?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.lockInvoice(manager, invoiceId);
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: irn,
|
||||
eimsAckDate: ackDate ?? null,
|
||||
eimsLastError: null,
|
||||
});
|
||||
await manager.update(EimsSystemState, reservation.stateId, {
|
||||
previousIrn: irn,
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
|
||||
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
|
||||
* The counter stays consumed either way.
|
||||
*/
|
||||
private async settleFailure(
|
||||
invoiceId: string,
|
||||
reservation: Reservation,
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
const api = err instanceof EimsApiException ? err : null;
|
||||
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
||||
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||
const lastError: EimsInvoiceError = {
|
||||
kind: api?.kind ?? "UNKNOWN",
|
||||
message: (err as Error)?.message ?? "unknown error",
|
||||
httpStatus: api?.httpStatus,
|
||||
details: api?.details,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: status,
|
||||
eimsLastError: lastError,
|
||||
} as QueryDeepPartialEntity<Invoice>);
|
||||
|
||||
await manager.update(
|
||||
EimsSystemState,
|
||||
reservation.stateId,
|
||||
deterministic
|
||||
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
|
||||
: {
|
||||
blockedReason:
|
||||
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
|
||||
`never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` +
|
||||
"can be chained until it is resolved with MoR.",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
||||
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
|
||||
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
||||
"/v1/register",
|
||||
request,
|
||||
);
|
||||
const irn = response?.body?.irn;
|
||||
if (!irn) {
|
||||
// The gateway answered, so this is deterministic: the document is not registered.
|
||||
throw new EimsApiException(
|
||||
"SCHEMA_VALIDATION",
|
||||
`EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`,
|
||||
response?.statusCode,
|
||||
);
|
||||
}
|
||||
return { irn, ackDate: response.body?.ackDate };
|
||||
}
|
||||
|
||||
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||
const invoice = await manager
|
||||
.createQueryBuilder(Invoice, "invoice")
|
||||
.setLock("pessimistic_write")
|
||||
.where("invoice.id = :invoiceId", { invoiceId })
|
||||
.getOne();
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/** Locks the system-state row, creating it on first use. */
|
||||
private async lockSystemState(
|
||||
manager: EntityManager,
|
||||
systemNumber: string,
|
||||
): Promise<EimsSystemState> {
|
||||
const select = () =>
|
||||
manager
|
||||
.createQueryBuilder(EimsSystemState, "state")
|
||||
.setLock("pessimistic_write")
|
||||
.where("state.system_number = :systemNumber", { systemNumber })
|
||||
.getOne();
|
||||
|
||||
const existing = await select();
|
||||
if (existing) return existing;
|
||||
|
||||
await manager.query(
|
||||
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1)
|
||||
ON CONFLICT (system_number) DO NOTHING`,
|
||||
[systemNumber],
|
||||
);
|
||||
const created = await select();
|
||||
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Header + buyer + lines — everything the mapper needs. */
|
||||
private async loadInvoiceForMapping(
|
||||
invoiceId: string,
|
||||
): Promise<Invoice & { lines: EimsMapperLine[] }> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId },
|
||||
relations: { company: true, companyProfile: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
|
||||
const lines: EimsMapperLine[] = await this.dataSource.query(
|
||||
`SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate",
|
||||
amount, currency, metadata
|
||||
FROM freight.invoice_lines
|
||||
WHERE invoice_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`,
|
||||
[invoiceId],
|
||||
);
|
||||
return Object.assign(invoice, { lines });
|
||||
}
|
||||
|
||||
private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||
const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
||||
const counter = invoice.eimsInvoiceCounter;
|
||||
return {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||
eimsIrn: invoice.eimsIrn ?? null,
|
||||
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||
eimsLastError: invoice.eimsLastError ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
|
||||
/**
|
||||
* Manual EIMS actions on an existing invoice.
|
||||
*
|
||||
* Invoices are produced by the freight workflow, not by a person, so these routes are **not** the
|
||||
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
||||
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
||||
*
|
||||
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
|
||||
* to named admins instead. They are also separate permissions: resolving clears the system-wide
|
||||
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
|
||||
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
|
||||
*
|
||||
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
||||
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
||||
* The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via
|
||||
* ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS.
|
||||
*/
|
||||
@ApiTags("eims")
|
||||
@ApiBearerAuth()
|
||||
@Controller("invoices")
|
||||
export class EimsInvoiceController {
|
||||
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
|
||||
|
||||
@Post(":id/eims/register")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.",
|
||||
})
|
||||
register(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.registration.registerInvoiceWithEims(id);
|
||||
}
|
||||
|
||||
@Post(":id/eims/verify")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
|
||||
verify(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.registration.verifyInvoiceWithEims(id);
|
||||
}
|
||||
|
||||
@Post(":id/eims/resolve")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsResolve)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.",
|
||||
})
|
||||
resolve(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ResolveEimsRegistrationDto,
|
||||
) {
|
||||
return this.registration.resolveEimsRegistration(id, dto);
|
||||
}
|
||||
|
||||
@Get(":id/eims/status")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||
@ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" })
|
||||
status(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.registration.getEimsStatus(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { EimsErrorResponse } from "./eims.types";
|
||||
|
||||
/**
|
||||
* Registration state of one invoice at MoR EIMS.
|
||||
*
|
||||
* `UNKNOWN` is not a synonym for failure: the request left this process and no answer came back,
|
||||
* so the invoice may or may not be registered at the gateway. It is never auto-retried — a resend
|
||||
* would risk a duplicate registration.
|
||||
*/
|
||||
export enum EimsInvoiceStatus {
|
||||
NotSubmitted = "NOT_SUBMITTED",
|
||||
Submitting = "SUBMITTING",
|
||||
Registered = "REGISTERED",
|
||||
Failed = "FAILED",
|
||||
Unknown = "UNKNOWN",
|
||||
}
|
||||
|
||||
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
||||
export interface EimsRegisterResponseBody {
|
||||
irn: string;
|
||||
ackDate?: string;
|
||||
signedQR?: string;
|
||||
signedInvoice?: string;
|
||||
status?: string;
|
||||
documentNumber?: string;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface EimsRegisterResponse {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
body?: EimsRegisterResponseBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner request of `POST /v1/verify`. The wire property is lowercase `irn` and is required —
|
||||
* omitting it yields a 400 "SCHEMA ERROR" reporting `$: required property 'irn' not found`.
|
||||
*/
|
||||
export interface EimsVerifyRequest {
|
||||
irn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `body` of a successful `POST /v1/verify` — the stored document echoed back. Note the casing
|
||||
* flip against the request: the response spells the reference `Irn`.
|
||||
*
|
||||
* Only the fields we actually assert on are typed; the rest of the echoed document (SellerDetails,
|
||||
* BuyerDetails, ItemList, …) is carried through untyped because nothing here reads it.
|
||||
*/
|
||||
export interface EimsVerifyResponseBody {
|
||||
Irn?: string;
|
||||
TransactionType?: string;
|
||||
DocumentDetails?: {
|
||||
Type?: string;
|
||||
DocumentNumber?: string;
|
||||
Date?: string;
|
||||
};
|
||||
Version?: string;
|
||||
[section: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EimsVerifyResponse {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
body?: EimsVerifyResponseBody;
|
||||
}
|
||||
|
||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||
export interface EimsInvoiceError {
|
||||
kind: string;
|
||||
message: string;
|
||||
httpStatus?: number;
|
||||
details?: EimsErrorResponse;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** What the status endpoint returns, and what a later invoice-detail panel will render. */
|
||||
export interface EimsInvoiceStatusView {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
eimsStatus: EimsInvoiceStatus;
|
||||
eimsIrn: string | null;
|
||||
eimsInvoiceCounter: number | null;
|
||||
eimsSubmittedAt: Date | null;
|
||||
eimsAckDate: string | null;
|
||||
eimsLastError: EimsInvoiceError | null;
|
||||
}
|
||||
@@ -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);
|
||||
78
apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
Normal file
78
apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config";
|
||||
|
||||
/**
|
||||
* Fixtures shared by the EIMS specs.
|
||||
*
|
||||
* Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that
|
||||
* file's `describe` blocks inside every importing suite, so the same tests run — and report —
|
||||
* twice.
|
||||
*/
|
||||
|
||||
export const EIMS_SYSTEM_NUMBER = "B0360154BA";
|
||||
export const EIMS_SYSTEM_TYPE = "SYS";
|
||||
|
||||
export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsInvoiceConfig => ({
|
||||
sellerLegalName: "Ethio-Djibouti Railway S.C.",
|
||||
sellerVatNumber: "0000000000",
|
||||
sellerPhone: "0911223344",
|
||||
sellerEmail: "finance@example.et",
|
||||
sellerRegion: "13",
|
||||
sellerWereda: "574",
|
||||
sellerCity: null,
|
||||
sellerSubCity: null,
|
||||
sellerHouseNumber: null,
|
||||
sellerLocality: null,
|
||||
taxCode: "VAT15",
|
||||
taxRatePercent: 15,
|
||||
exciseTaxValue: 0,
|
||||
incomeWithholdValue: 0,
|
||||
transactionWithholdValue: 0,
|
||||
transactionType: "B2B",
|
||||
natureOfSupplies: "Service",
|
||||
paymentMode: "CASH",
|
||||
paymentTerm: "IMMIDIATE",
|
||||
unitDefault: "PCS",
|
||||
buyerCountryCode: null,
|
||||
cashierName: null,
|
||||
salesPersonName: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
|
||||
enabled: true,
|
||||
baseUrl: "https://core.mor.gov.et",
|
||||
clientId: "cid",
|
||||
clientSecret: "super-secret-value",
|
||||
apiKey: "super-secret-apikey",
|
||||
tin: "0000034558",
|
||||
systemNumber: EIMS_SYSTEM_NUMBER,
|
||||
systemType: EIMS_SYSTEM_TYPE,
|
||||
privateKeyPath: "/dev/null",
|
||||
certificatePath: "/dev/null",
|
||||
httpTimeoutMs: 30_000,
|
||||
tokenSkewMs: 45_000,
|
||||
autoSubmit: false,
|
||||
autoSubmitCron: "0 */5 * * * *",
|
||||
autoSubmitMaxAgeDays: 3,
|
||||
invoice: eimsInvoiceConfig(),
|
||||
...over,
|
||||
});
|
||||
|
||||
/**
|
||||
* A structurally real access token. MoR stamps the source-system identity into the JWT payload and
|
||||
* `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is
|
||||
* never verified locally — it is MoR's, signed with MoR's key.
|
||||
*
|
||||
* Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo).
|
||||
*/
|
||||
export const eimsToken = (claims: Record<string, unknown> = {}): string => {
|
||||
const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims };
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value === undefined) delete (payload as Record<string, unknown>)[key];
|
||||
}
|
||||
return [
|
||||
"eyJhbGciOiJSUzI1NiJ9",
|
||||
Buffer.from(JSON.stringify(payload)).toString("base64url"),
|
||||
"signature",
|
||||
].join(".");
|
||||
};
|
||||
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"}`);
|
||||
}
|
||||
37
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
37
apps/edr-freight-api/src/modules/eims/eims.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
|
||||
/**
|
||||
* MoR EIMS e-invoicing: signed transport, authentication, and manual single-invoice registration.
|
||||
*
|
||||
* Exports only what other modules will consume; the credential loader and signer stay internal so
|
||||
* the private key has exactly one user. Nothing here is called from invoice creation.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
providers: [
|
||||
EimsCredentialsProvider,
|
||||
EimsSignerService,
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsAutoSubmitService,
|
||||
],
|
||||
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
|
||||
})
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
/**
|
||||
* One row per MoR system number, holding the sequence state EIMS expects across registrations:
|
||||
* the next `SourceSystem.InvoiceCounter` and the IRN that the next document must chain to via
|
||||
* `ReferenceDetails.PreviousIrn`.
|
||||
*
|
||||
* Registration locks this row `FOR UPDATE` for the duration of the submission, which is what keeps
|
||||
* two concurrent registrations from claiming the same counter or breaking the IRN chain.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "eims_system_state" })
|
||||
export class EimsSystemState extends BaseEntity {
|
||||
@Column({ name: "system_number", type: "varchar", length: 32, unique: true })
|
||||
systemNumber!: string;
|
||||
|
||||
/** Counter to send on the next registration; advanced only once an attempt has consumed it. */
|
||||
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
|
||||
nextInvoiceCounter!: number;
|
||||
|
||||
/** IRN of the last successful registration; null until the first one succeeds. */
|
||||
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
||||
previousIrn?: string | null;
|
||||
|
||||
/**
|
||||
* Invoice holding the current reservation. Committed before the HTTP call, so it survives a
|
||||
* crash and blocks a blind resubmission of a document that may already have reached MoR.
|
||||
*/
|
||||
@Column({ name: "in_flight_invoice_id", type: "uuid", nullable: true })
|
||||
inFlightInvoiceId?: string | null;
|
||||
|
||||
/** Counter handed to the in-flight submission. */
|
||||
@Column({ name: "in_flight_counter", type: "bigint", nullable: true })
|
||||
inFlightCounter?: number | null;
|
||||
|
||||
/**
|
||||
* Why registration is blocked for this system number. Set when a submission ends ambiguously:
|
||||
* the IRN is unknown, so no further document can chain correctly until it is resolved.
|
||||
*/
|
||||
@Column({ name: "blocked_reason", type: "text", nullable: true })
|
||||
blockedReason?: string | null;
|
||||
}
|
||||
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;
|
||||
});
|
||||
@@ -467,6 +467,21 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:export",
|
||||
"Download invoice document",
|
||||
),
|
||||
// Filing with the tax authority is its own grant: registration is irreversible at MoR, so it
|
||||
// must not ride along with the right to download an invoice PDF.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-000000000005",
|
||||
"edr_freight_app:invoices:eims_register",
|
||||
"Register invoice with MoR EIMS",
|
||||
),
|
||||
// Separate from registering: resolving an unacknowledged submission clears the
|
||||
// system-wide chain block and can record an IRN against an invoice, so it is a
|
||||
// supervisor/admin action rather than an operational one.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-000000000006",
|
||||
"edr_freight_app:invoices:eims_resolve",
|
||||
"Resolve a blocked MoR EIMS submission",
|
||||
),
|
||||
];
|
||||
|
||||
// E. First / last mile operations
|
||||
@@ -1591,6 +1606,8 @@ export const FREIGHT_PERMS = {
|
||||
invoices: {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
},
|
||||
firstMile: {
|
||||
view: "edr_freight_app:first_mile:view",
|
||||
@@ -2080,6 +2097,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve.
|
||||
// Invoices are filed with MoR by the workflow, not by a person, so filing is not a
|
||||
// Finance job function — the endpoints exist for controlled testing and exceptional
|
||||
// operations, and are assigned to named admins rather than a role preset.
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user