diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index 2c636eee4..5a145a0db 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -172,14 +172,23 @@ 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.
-# Required, and deliberately unset: the choice is a tax position, not a default.
+# Required, and deliberately unset here: the choice is a tax position, not a default.
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
-# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
+# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
EIMS_TAX_CODE=
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
+# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
+# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
+# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
+# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
+EIMS_TAX_CODE_BY_CHARGE_TYPE=
+EIMS_TAX_RATE_BY_CHARGE_TYPE=
+# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
+EIMS_EXCISE_BY_CHARGE_TYPE=
+EIMS_DISCOUNT_BY_CHARGE_TYPE=
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 3bab613fb..1efe750fc 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -112,6 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
+import { AuditModule } from "./modules/audit/audit.module";
import { RequestLogMiddleware } from "@edr/api-common";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -244,6 +245,7 @@ if (!process.env.APPLICATION_NAME) {
EimsModule,
FleetHistoryModule,
AiModule,
+ AuditModule,
],
providers: [
EdrOrgSeeder,
diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts
index 0aefe3695..e898cb930 100644
--- a/apps/edr-freight-api/src/common/request-log-context.spec.ts
+++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts
@@ -1,5 +1,7 @@
import { Logger } from "@nestjs/common";
+import type { Repository } from "typeorm";
import {
+ BaseRepository,
RequestLogMiddleware,
getLogContext,
logCtx,
@@ -47,10 +49,13 @@ describe("logCtx", () => {
describe("RequestLogMiddleware", () => {
it("emits one canonical JSON line carrying the collected context", () => {
+ // Raw stdout, not the Nest logger — the line must be parsable JSON with no
+ // "[Nest] … LOG [request]" prefix in front of it.
const lines: string[] = [];
- jest
- .spyOn(Logger.prototype, "warn")
- .mockImplementation((m) => lines.push(String(m)));
+ jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
+ lines.push(String(chunk));
+ return true;
+ });
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record void> = {};
@@ -60,10 +65,40 @@ describe("RequestLogMiddleware", () => {
originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings",
route: { path: "/:id/submit" },
- headers: { "user-agent": "jest", "x-request-id": "req-42" },
+ headers: {
+ "user-agent": "jest",
+ "x-request-id": "req-42",
+ authorization: "Bearer tok",
+ "x-client-app": "freight-backoffice",
+ "current-project-id": "proj-3",
+ },
ip: "10.0.0.1",
query: { dry: "1" },
- user: { id: "u-7" },
+ user: {
+ id: "u-7",
+ sessionId: "sess-9",
+ userType: "STAFF",
+ status: "ACTIVE",
+ username: "nati",
+ email: "nati@example.com",
+ phoneNumber: "0911000000",
+ name: { en: "Nati" },
+ roles: [{ key: "freight_operations" }],
+ permissions: [{ key: "a" }, { key: "b" }],
+ employee: {
+ id: "emp-1",
+ organizationId: "org-1",
+ unitId: "unit-2",
+ position: {
+ id: "pos-5",
+ key: "ops_officer",
+ employeePositionId: "ep-6",
+ isDelegate: true,
+ delegatorId: "pos-1",
+ positionType: { key: "operations" },
+ },
+ },
+ },
};
const res = {
statusCode: 409,
@@ -82,7 +117,11 @@ describe("RequestLogMiddleware", () => {
listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1);
+ expect(lines[0].endsWith("\n")).toBe(true);
+ expect(lines[0].startsWith("{")).toBe(true);
expect(JSON.parse(lines[0])).toMatchObject({
+ level: "warn",
+ logger: "request",
type: "http_request",
requestId: "req-42",
method: "POST",
@@ -96,7 +135,65 @@ describe("RequestLogMiddleware", () => {
bookingId: "b-1",
booking: { outcome: "REJECTED" },
});
+ expect(JSON.parse(lines[0]).auth).toEqual({
+ authenticated: true,
+ hasBearer: true,
+ clientApp: "freight-backoffice",
+ userId: "u-7",
+ sessionId: "sess-9",
+ userType: "STAFF",
+ userStatus: "ACTIVE",
+ roles: ["freight_operations"],
+ permissionCount: 2,
+ employeeId: "emp-1",
+ organizationId: "org-1",
+ unitId: "unit-2",
+ positionId: "pos-5",
+ positionKey: "ops_officer",
+ positionType: "operations",
+ employeePositionId: "ep-6",
+ isDelegate: true,
+ delegatorId: "pos-1",
+ projectId: "proj-3",
+ });
+ // No personal data reaches the line, whatever the token carried.
+ expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks();
});
});
+
+describe("BaseRepository write trail", () => {
+ class TestRepo extends BaseRepository<{ id: string; status?: string }> {
+ constructor(repo: Repository<{ id: string; status?: string }>) {
+ super(repo);
+ }
+ }
+
+ const typeormRepo = {
+ metadata: { tableName: "booking" },
+ create: (data: unknown) => data,
+ save: async (data: unknown) => data,
+ update: async () => undefined,
+ findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
+ softDelete: async () => undefined,
+ delete: async () => undefined,
+ } as unknown as Repository<{ id: string; status?: string }>;
+
+ it("records creates, status changes and deletes without any service opting in", async () => {
+ const ctx = await runWithLogContext({}, async () => {
+ const repo = new TestRepo(typeormRepo);
+ await repo.create({ id: "b-1" });
+ await repo.update("b-1", { status: "SUBMITTED" });
+ await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
+ await repo.softDelete("b-1");
+ return getLogContext();
+ });
+
+ expect(ctx).toEqual({
+ db: { created: { booking: 1 }, updated: { booking: 2 } },
+ statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
+ deleted: [{ entity: "booking", id: "b-1" }],
+ });
+ });
+});
diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts
index a8a929ca5..cf77072e0 100644
--- a/apps/edr-freight-api/src/config/eims.config.ts
+++ b/apps/edr-freight-api/src/config/eims.config.ts
@@ -89,8 +89,30 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record;
+ /**
+ * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
+ * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
+ * `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
+ * treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
+ * cannot express that. Values are raw strings; the context builder parses/validates them.
+ */
+ taxCodeByChargeType: Record;
+ taxRateByChargeType: Record;
+ /** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
+ * types not listed fall back to `exciseTaxValue` / 0 respectively. */
+ exciseByChargeType: Record;
+ discountByChargeType: Record;
cashierName: string | null;
salesPersonName: string | null;
+ /**
+ * TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
+ * buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
+ * one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
+ * of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
+ * Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
+ */
+ buyerIdType: string | null;
+ buyerIdNumber: string | null;
}
const REQUIRED_VARS = [
@@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => {
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
+ taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
+ taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
+ exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
+ discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
+ buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
+ buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
},
};
diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts
index c62a875bf..2f1991df2 100644
--- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts
@@ -44,6 +44,7 @@ const UNIT_LABELS: Record = {
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_TON_KM: 'per ton per km',
+ PER_LITER: 'per liter',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
+ FUEL: 'Fuel surcharge',
};
@Injectable()
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
continue;
}
+ // Fuel is sold per lane + commodity — only lanes matching the contract's
+ // direction belong on its schedule, labeled with their leg.
+ if (rate.trigger === 'FUEL') {
+ if (this.fuelDirectionMatches(rate, direction)) {
+ surcharges.push(this.fuelRow(rate));
+ }
+ continue;
+ }
+
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate));
}
@@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder {
};
}
+ private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
+ const want =
+ direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
+ return rate.tradeDirection === want;
+ }
+
+ /**
+ * Fuel row — the lane matters, so it rides along in the charge label.
+ * Per-liter collapses to one flat total (base liters × rate value); the
+ * customer only ever sees the final price.
+ */
+ private fuelRow(rate: Rate): RateScheduleRow {
+ const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
+ const destination =
+ rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
+ const perLiter = rate.rateUnit === 'PER_LITER';
+ return {
+ route: `Fuel surcharge (${origin} → ${destination})`,
+ cargo: this.cargoLabel(rate),
+ currency: rate.currency,
+ amount: this.formatAmount(
+ perLiter
+ ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
+ : rate.rateValue,
+ ),
+ unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
+ };
+ }
+
private surchargeRow(rate: Rate): RateScheduleRow {
return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
diff --git a/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts
new file mode 100644
index 000000000..e76591205
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts
@@ -0,0 +1,69 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Bulk contract templates gain trade direction, so the unique key becomes
+ * (cargo type, direction, customs option) instead of (cargo type, customs).
+ *
+ * Intercity is domestic and crosses no border, so it has no customs variant at
+ * all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs.
+ * The unique index coalesces that NULL so two intercity templates for the same
+ * cargo type still collide (plain NULLs never do).
+ *
+ * No backfill: staff-created bulk templates are keyed by cargo_type_id and no
+ * such row exists yet — the seeded direction-keyed bulk rows were retired by
+ * 3320000000000 and carry a NULL cargo_type_id. The five system container
+ * templates are untouched: cargo_type_id IS NULL keeps them out of both the
+ * index and the check.
+ */
+export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_templates
+ ADD COLUMN IF NOT EXISTS trade_direction varchar(20)
+ `);
+
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
+ );
+
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
+ ON freight.contract_templates
+ (cargo_type_id, trade_direction, COALESCE(with_customs, false))
+ WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_templates
+ DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_templates
+ ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
+ cargo_type_id IS NULL
+ OR (
+ trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
+ AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
+ )
+ )
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_templates
+ DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
+ `);
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
+ );
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
+ ON freight.contract_templates (cargo_type_id, with_customs)
+ WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts
new file mode 100644
index 000000000..dfd0d25cf
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts
@@ -0,0 +1,62 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Fuel surcharge, sold per lane + commodity:
+ *
+ * - cargo_types.has_fuel marks the commodities that incur it (same shape as
+ * has_lashing — the booking's cargo type flag is what fires the charge).
+ * - rates.base_liters carries the liters a PER_LITER fuel rate bills
+ * (price = base_liters × rate_value, once per booking). NULL on every other
+ * rate shape, including PER_WAGON fuel rates (wagons × rate_value).
+ * - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced
+ * per origin → destination leg like customs clearance and container return.
+ */
+export class FuelSurcharge3430000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.cargo_types
+ ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.rates
+ ADD COLUMN IF NOT EXISTS base_liters numeric(14,4)
+ `);
+
+ await queryRunner.query(
+ `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
+ );
+ await queryRunner.query(`
+ ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
+ deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
+ CASE
+ WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
+ OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
+ THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
+ ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
+ END
+ )
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
+ );
+ await queryRunner.query(`
+ ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
+ deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
+ CASE
+ WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
+ OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
+ THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
+ ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
+ END
+ )
+ `);
+ await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`);
+ await queryRunner.query(
+ `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts
new file mode 100644
index 000000000..ec3faf9eb
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts
@@ -0,0 +1,33 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed
+ * live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as
+ * `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-`
+ * prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already
+ * accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING`
+ * and the system-wide reservation stuck in-flight with no block/alert (see
+ * `EimsInvoiceRegistrationService` for the accompanying code fix).
+ *
+ * Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or
+ * length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's
+ * real production shape — guessing another fixed bound risks the exact same failure again.
+ */
+export class WidenEimsIrn3450000000000 implements MigrationInterface {
+ name = "WidenEimsIrn3450000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ ALTER COLUMN eims_irn TYPE text
+ `);
+ }
+
+ /** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ ALTER COLUMN eims_irn TYPE varchar(64)
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts
new file mode 100644
index 000000000..dedf2d05f
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts
@@ -0,0 +1,20 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */
+export class AddEimsSignedQr3460000000000 implements MigrationInterface {
+ name = "AddEimsSignedQr3460000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ ADD COLUMN IF NOT EXISTS eims_signed_qr text
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ DROP COLUMN IF EXISTS eims_signed_qr
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts
new file mode 100644
index 000000000..7092e5518
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts
@@ -0,0 +1,26 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */
+export class EimsCancellation3470000000000 implements MigrationInterface {
+ name = "EimsCancellation3470000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz,
+ ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64),
+ ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8),
+ ADD COLUMN IF NOT EXISTS eims_cancellation_remark text
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ DROP COLUMN IF EXISTS eims_cancelled_at,
+ DROP COLUMN IF EXISTS eims_cancellation_date,
+ DROP COLUMN IF EXISTS eims_cancellation_reason_code,
+ DROP COLUMN IF EXISTS eims_cancellation_remark
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts
new file mode 100644
index 000000000..15a343f06
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too
+ * (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same
+ * varchar(64) on the next successful registration.
+ */
+export class WidenPreviousIrn3480000000000 implements MigrationInterface {
+ name = "WidenPreviousIrn3480000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.eims_system_state
+ ALTER COLUMN previous_irn TYPE text
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.eims_system_state
+ ALTER COLUMN previous_irn TYPE varchar(64)
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts
new file mode 100644
index 000000000..3d5ab02e9
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts
@@ -0,0 +1,34 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/** `freight.eims_receipts` — see `EimsReceipt` entity. */
+export class EimsReceipts3490000000000 implements MigrationInterface {
+ name = "EimsReceipts3490000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.eims_receipts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ invoice_id uuid NOT NULL REFERENCES freight.invoices(id),
+ kind varchar(16) NOT NULL,
+ status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
+ receipt_number varchar(64) NOT NULL,
+ rrn text,
+ qr text,
+ ack_status varchar(8),
+ submitted_at timestamptz,
+ last_error jsonb,
+ request jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz
+ )
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
index 576c7b166..d9eef5a43 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
@@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
+ { get: () => undefined } as never, // config
);
});
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
+ { get: () => undefined } as never, // config
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
+ { get: () => undefined } as never, // config
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
+ { get: () => undefined } as never, // config
);
return { service, mg, events };
}
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
+ { get: () => undefined } as never, // config
);
return { service, mg, events };
}
@@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never,
+ {} as never, // config
);
return { service, defaultManager, txManager, transaction };
};
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never,
+ {} as never, // config
);
return { service, manager };
};
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never,
{} as never,
+ {} as never, // config
);
return { service, repo };
};
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never,
{} as never,
+ {} as never, // config
);
return { service, repo };
};
@@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
});
});
});
+
+describe("BillingService.document", () => {
+ const invoiceRow = (over: Record = {}) => ({
+ id: "inv-1",
+ invoiceNumber: "INV-20260812-00001",
+ source: "booking",
+ sourceId: "booking-1",
+ status: Freight.InvoiceStatus.Pending,
+ type: "freight",
+ currency: "ETB",
+ subtotalAmount: 100,
+ taxAmount: 0,
+ totalAmount: 100,
+ paidAmount: 0,
+ balanceAmount: 100,
+ issuedAt: new Date(2026, 7, 12),
+ dueAt: new Date(2026, 7, 19),
+ eimsIrn: null,
+ eimsSignedQr: null,
+ company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
+ ...over,
+ });
+
+ const build = (invoice: Record) => {
+ const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
+ const service = new BillingService(
+ {} as never,
+ { findById: jest.fn().mockResolvedValue(invoice) } as never,
+ { findAll: jest.fn().mockResolvedValue([]) } as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ { render } as never,
+ {} as never,
+ {
+ get: (key: string) =>
+ key === "eims"
+ ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
+ : undefined,
+ } as never, // config
+ );
+ return { service, render };
+ };
+
+ it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
+ const { service, render } = build(invoiceRow());
+
+ await service.document("inv-1");
+
+ const model = render.mock.calls[0][0];
+ expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
+ expect(model.qrImageUrl).toBeNull();
+ });
+
+ it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
+ const { service, render } = build(invoiceRow());
+
+ await service.document("inv-1");
+
+ const model = render.mock.calls[0][0];
+ expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
+ expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
+ expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
+ });
+
+ it("omits the VAT row when the buyer company has none", async () => {
+ const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
+
+ await service.document("inv-1");
+
+ const model = render.mock.calls[0][0];
+ expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
+ });
+
+ it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
+ const { service, render } = build(invoiceRow());
+
+ await service.document("inv-1");
+
+ const model = render.mock.calls[0][0];
+ expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
+ expect(model.summary).toContainEqual({
+ label: "Seller VAT No.",
+ value: "43256663343256663322",
+ });
+ });
+
+ it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
+ const { service, render } = build(
+ invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
+ );
+
+ await service.document("inv-1");
+
+ const model = render.mock.calls[0][0];
+ expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
+ expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 9a31466f0..caa25fbaa 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -1,4 +1,5 @@
import { Freight, PaymentReferenceType } from "@edr/types";
+import { ConfigService } from "@nestjs/config";
import {
BadRequestException,
forwardRef,
@@ -8,9 +9,11 @@ import {
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
+import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
+import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
@@ -160,6 +163,7 @@ export class BillingService {
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
+ private readonly config: ConfigService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -383,7 +387,7 @@ export class BillingService {
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
- this.toDocumentModel(invoice, "INVOICE"),
+ await this.toDocumentModel(invoice, "INVOICE"),
);
}
@@ -396,15 +400,24 @@ export class BillingService {
);
}
return this.invoiceDocuments.render(
- this.toDocumentModel(invoice, "RECEIPT"),
+ await this.toDocumentModel(invoice, "RECEIPT"),
);
}
+ /**
+ * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
+ * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
+ * not a payload we encode ourselves. Wrapped in a data URL, nothing more.
+ */
+ private renderEimsQr(signedQr: string): string {
+ return `data:image/png;base64,${signedQr}`;
+ }
+
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
- private toDocumentModel(
+ private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
- ): InvoiceDocumentModel {
+ ): Promise {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
@@ -422,6 +435,43 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
+ const summary: InvoiceDocumentModel["summary"] = [
+ // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
+ // filed against, not just the seller. VatNumber shown only when the company has one.
+ { label: "Buyer", value: invoice.company?.name ?? null },
+ { label: "Buyer TIN", value: invoice.company?.tin ?? null },
+ ...(invoice.company?.vatNumber
+ ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
+ : []),
+ { label: "Status", value: invoice.status },
+ { label: "Type", value: invoice.type },
+ { label: "Reference", value: invoice.sourceId },
+ { label: "Currency", value: invoice.currency },
+ {
+ label: "Issued",
+ value: invoice.issuedAt
+ ? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
+ : null,
+ },
+ {
+ label: "Due",
+ value: invoice.dueAt
+ ? new Date(invoice.dueAt).toLocaleDateString("en-GB")
+ : null,
+ },
+ ];
+
+ // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
+ // codebase). Shown only when actually configured, same as the buyer VAT row.
+ const eimsCfg = this.config.get("eims");
+ if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
+ if (eimsCfg?.invoice?.sellerVatNumber) {
+ summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
+ }
+
+ // MoR EIMS reference — only once actually registered, never a placeholder row.
+ if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
+
return {
kind,
title,
@@ -429,24 +479,7 @@ export class BillingService {
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
- summary: [
- { label: "Status", value: invoice.status },
- { label: "Type", value: invoice.type },
- { label: "Reference", value: invoice.sourceId },
- { label: "Currency", value: invoice.currency },
- {
- label: "Issued",
- value: invoice.issuedAt
- ? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
- : null,
- },
- {
- label: "Due",
- value: invoice.dueAt
- ? new Date(invoice.dueAt).toLocaleDateString("en-GB")
- : null,
- },
- ],
+ summary,
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
@@ -457,6 +490,7 @@ export class BillingService {
currency: l.currency,
})),
totals,
+ qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
};
}
@@ -951,6 +985,24 @@ export class BillingService {
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
+ // Every invoice status move in the app funnels through here — money
+ // changing state is the single most-asked question in support.
+ logCtx(
+ {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoiceNumber,
+ source: invoice.source,
+ sourceId: invoice.sourceId,
+ from: invoice.status,
+ to: status,
+ event,
+ amount: Number(invoice.totalAmount),
+ currency: invoice.currency,
+ paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
+ },
+ { path: "invoiceTransitions", mode: "push" },
+ );
+
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
@@ -1320,6 +1372,21 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance.");
}
+ logCtx(
+ {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoiceNumber,
+ source: invoice.source,
+ sourceId: invoice.sourceId,
+ companyId: invoice.companyId,
+ amountDue,
+ currency: invoice.currency,
+ method: opts.method ?? "TELEBIRR",
+ platform: opts.platform,
+ },
+ { path: "payment.payInvoice" },
+ );
+
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
@@ -1443,7 +1510,24 @@ export class BillingService {
// first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
});
- if (!invoice) return null;
+ if (!invoice) {
+ logCtx(
+ { paymentId, outcome: "no-invoice-for-payment" },
+ { path: "payment.settleInvoice" },
+ );
+ return null;
+ }
+
+ logCtx(
+ {
+ paymentId,
+ providerTxnId,
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoiceNumber,
+ invoiceStatus: invoice.status,
+ },
+ { path: "payment.settleInvoice" },
+ );
const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES,
@@ -1453,6 +1537,12 @@ export class BillingService {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly.
+ logCtx(
+ invoice.status === Freight.InvoiceStatus.Paid
+ ? "already-paid"
+ : "captured-with-nowhere-to-land",
+ { path: "payment.settleInvoice.outcome", mode: "set" },
+ );
if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts
new file mode 100644
index 000000000..00e590e17
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts
@@ -0,0 +1,46 @@
+import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
+
+const model = (over: Partial = {}): InvoiceDocumentModel => ({
+ kind: "INVOICE",
+ title: "Freight",
+ documentNumber: "INV-20260812-00001",
+ issuedAt: new Date(2026, 7, 12),
+ status: "PENDING",
+ currency: "ETB",
+ summary: [{ label: "Status", value: "PENDING" }],
+ lines: [],
+ totals: [{ label: "Total", amount: 100, grand: true }],
+ ...over,
+});
+
+describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
+ const service = new InvoiceDocumentService({} as never, {} as never);
+
+ it("renders no QR block when qrImageUrl is unset", () => {
+ const html = service.buildHtml(model());
+ expect(html).not.toContain('class="qr"');
+ });
+
+ it("renders the QR image when qrImageUrl is set", () => {
+ const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
+ expect(html).toContain('class="qr"');
+ expect(html).toContain('src="data:image/png;base64,QR"');
+ });
+
+ it("still shows the IRN text row via the ordinary summary grid", () => {
+ const html = service.buildHtml(
+ model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }),
+ );
+ expect(html).toContain("EIMS IRN");
+ expect(html).toContain("IRN-123");
+ });
+
+ it("widens the summary's right margin only when a QR is present, to clear the QR block", () => {
+ // "summary-with-qr" also appears in the always-present ` +
`Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}
` +
`Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
- `Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
- `Tickets: ${esc(String(stats?.totalTickets ?? 0))}
` +
+ `Bookings: ${esc(String(totalBookingsCount))} | ` +
+ `Tickets: ${esc(String(totalTicketsCount))}
` +
`