Merge pull request #1267 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-12 20:42:25 +03:00
committed by GitHub
113 changed files with 5331 additions and 875 deletions

View File

@@ -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'.

View File

@@ -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,

View File

@@ -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<string, () => 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" }],
});
});
});

View File

@@ -89,8 +89,30 @@ export interface EimsInvoiceConfig {
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* 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<string, string>;
taxRateByChargeType: Record<string, string>;
/** 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<string, string>;
discountByChargeType: Record<string, string>;
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,
},
};

View File

@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
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<Record<Rate['trigger'], string>> = {
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),

View File

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

View File

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

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ALTER COLUMN eims_irn TYPE varchar(64)
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_signed_qr text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_signed_qr
`);
}
}

View File

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

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ALTER COLUMN previous_irn TYPE text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ALTER COLUMN previous_irn TYPE varchar(64)
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`);
}
}

View File

@@ -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<string, unknown> = {}) => ({
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<string, unknown>) => {
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");
});
});

View File

@@ -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<InvoiceDocumentModel> {
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<EimsConfig>("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} ` +

View File

@@ -0,0 +1,46 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): 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 <style> rule, so the check has to be
// the actual div's class attribute, not a bare substring match.
expect(service.buildHtml(model())).not.toContain('class="summary summary-with-qr"');
expect(service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" }))).toContain(
'class="summary summary-with-qr"',
);
});
});

View File

@@ -62,6 +62,12 @@ export interface InvoiceDocumentModel {
* explicitly only to override that default for one document.
*/
stampImageUrl?: string | null;
/**
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
* see that column's comment). Set only once an invoice is actually registered; the IRN text
* itself goes through the ordinary `summary` rows, not a dedicated field.
*/
qrImageUrl?: string | null;
}
/**
@@ -96,9 +102,11 @@ export class InvoiceDocumentService {
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
// ponytail: still draws the plain vector seal, not the uploaded stamp
// image — embedding a raster image needs a new PDF XObject primitive
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
// carry the real stamp too; today it's a rare degraded fallback.
// image, and omits the EIMS QR entirely — embedding a raster image
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
// when the Chromium-less path needs to carry the real stamp/QR too;
// today it's a rare degraded fallback. The IRN text itself still
// comes through (buildFallbackPdf renders model.summary same as HTML).
fallback: () => this.buildFallbackPdf(resolvedModel),
}),
};
@@ -243,6 +251,10 @@ export class InvoiceDocumentService {
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
: "";
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join("");
@@ -281,8 +293,19 @@ export class InvoiceDocumentService {
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
${sealImageCss()}
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
.qr img { width: 90px; height: 90px; }
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
.summary.summary-with-qr { margin-right: 270px; }
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
the text itself was still escaping the box's own right edge until this was added). */
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
@@ -309,7 +332,8 @@ export class InvoiceDocumentService {
</div>
</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
${qrMarkup}
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
<table>
<thead>
<tr>

View File

@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
salesPersonName: null,
transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
natureOfSupplies: "Service",
unitDefault: "PCS",
incomeWithholdValue: 0,
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
context({
taxForLine: (line) =>
line.chargeType === "RAIL_FREIGHT"
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
}),
);
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
TaxCode: "VAT15",
TaxAmount: 1500,
ExciseTaxValue: 0,
Discount: 0,
TotalLineAmount: 11500,
Unit: "PCS",
NatureOfSupplies: "service",
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
TaxCode: "EXEMPT",
TaxAmount: 0,
ExciseTaxValue: 50,
// Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
Discount: 25,
TotalLineAmount: 1050,
Unit: "CTR",
});
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
toEimsInvoice(
invoice(),
seller,
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }),
),
).toThrow(/unresolved tax treatment for line 1/);
expect(() =>
toEimsInvoice(
invoice(),
seller,
context({
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }),
}),
),
).toThrow(/unresolved tax treatment for line 1/);
});

View File

@@ -183,6 +183,12 @@ export interface EimsLineTax {
code: string;
ratePercent: number;
exciseTaxValue: number;
/**
* Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every
* prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not
* start subtracting this without a confirmed MoR example.
*/
discount: number;
}
export interface EimsMapperContext {
@@ -336,7 +342,13 @@ export function toEimsInvoice(
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)) {
if (
!tax ||
!tax.code ||
!Number.isFinite(tax.ratePercent) ||
!Number.isFinite(tax.exciseTaxValue) ||
!Number.isFinite(tax.discount)
) {
throw new Error(
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
`on invoice ${invoice.invoiceNumber}`,
@@ -349,7 +361,7 @@ export function toEimsInvoice(
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
return {
Discount: 0,
Discount: round2(tax.discount),
ExciseTaxValue,
HarmonizationCode: null,
NatureOfSupplies: natureOfSupplies,

View File

@@ -111,10 +111,22 @@ export class Invoice extends BaseEntity {
@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 })
/**
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
*/
@Column({ name: "eims_irn", type: "text", nullable: true })
eimsIrn?: string | null;
/**
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
*/
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
eimsSignedQr?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
@@ -133,4 +145,24 @@ export class Invoice extends BaseEntity {
/** 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;
/**
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
*/
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
eimsCancelledAt?: Date | null;
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
eimsCancellationDate?: string | null;
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
eimsCancellationReasonCode?: string | null;
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
eimsCancellationRemark?: string | null;
}

View File

@@ -1,4 +1,4 @@
import { BaseRepository } from '@edr/api-common';
import { BaseRepository, logCtx } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
authorId?: string,
): Promise<BookingReviewNote> {
const repo = this.dataSource.getRepository(BookingReviewNote);
// Every rejection/cancellation/change-request reason in the booking flow is
// written through here — the "why" behind the status change on the same log
// line as the status change itself.
logCtx(
{ bookingId, type, note, authorId },
{ path: 'reviewNotes', mode: 'push' },
);
return repo.save(
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
);

View File

@@ -9,7 +9,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
@@ -2115,6 +2115,21 @@ export class BookingsService {
throw new NotFoundException(`Booking ${id} not found`);
}
// Nearly every booking flow loads the booking through here, so this one
// call puts the human-searchable reference + entry state on the request log
// line for all of them. Entry state only — the write trail (statusChanges)
// shows where it ended up.
logCtx(
{
id: booking.id,
reference: booking.reference,
statusAtEntry: booking.status,
companyId: booking.companyId,
contractId: booking.contractId ?? undefined,
},
{ path: "booking" },
);
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {

View File

@@ -0,0 +1,134 @@
import { BadRequestException } from '@nestjs/common';
import { ContractTemplatesRepository } from './contract-templates.repository';
import { ContractTemplatesService } from './contract-templates.service';
import {
bulkTemplateCode,
bulkTemplateDirectionFor,
} from './entities/contract-template.entity';
describe('bulkTemplateDirectionFor', () => {
it('maps the contract-side DOMESTIC onto the template-side INTERCITY', () => {
expect(bulkTemplateDirectionFor('DOMESTIC')).toBe('INTERCITY');
expect(bulkTemplateDirectionFor('INTERCITY')).toBe('INTERCITY');
expect(bulkTemplateDirectionFor(null)).toBe('INTERCITY');
expect(bulkTemplateDirectionFor(undefined)).toBe('INTERCITY');
expect(bulkTemplateDirectionFor('IMPORT')).toBe('IMPORT');
expect(bulkTemplateDirectionFor('EXPORT')).toBe('EXPORT');
});
});
describe('bulkTemplateCode', () => {
it('gives each direction/customs combination its own code', () => {
expect(bulkTemplateCode('STEEL', 'IMPORT', true)).toBe('BULK_IMPORT_STEEL_CUSTOMS');
expect(bulkTemplateCode('STEEL', 'IMPORT', false)).toBe(
'BULK_IMPORT_STEEL_NO_CUSTOMS',
);
expect(bulkTemplateCode('STEEL', 'EXPORT', true)).toBe('BULK_EXPORT_STEEL_CUSTOMS');
expect(bulkTemplateCode('STEEL', 'EXPORT', false)).toBe(
'BULK_EXPORT_STEEL_NO_CUSTOMS',
);
});
it('leaves intercity unsuffixed — it crosses no border', () => {
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
});
it('produces 5 distinct codes per cargo type', () => {
const codes = [
bulkTemplateCode('STEEL', 'IMPORT', true),
bulkTemplateCode('STEEL', 'IMPORT', false),
bulkTemplateCode('STEEL', 'EXPORT', true),
bulkTemplateCode('STEEL', 'EXPORT', false),
bulkTemplateCode('STEEL', 'INTERCITY', null),
];
expect(new Set(codes).size).toBe(5);
});
});
describe('ContractTemplatesService bulk create/resolve', () => {
function build() {
const repository = {
findCargoType: jest.fn(() =>
Promise.resolve({
id: 'cargo-1',
code: 'STEEL',
cargoTypeName: 'Steel',
hasContractTemplate: true,
}),
),
findByCargoCombo: jest.fn(() => Promise.resolve(null)),
findActiveBulkTemplate: jest.fn(() => Promise.resolve(null)),
findByCode: jest.fn(() => Promise.resolve(null)),
saveTemplate: jest.fn((template) => Promise.resolve(template)),
} as unknown as ContractTemplatesRepository;
return {
repository,
service: new ContractTemplatesService(repository, {} as never),
};
}
it('stores direction and customs on an import template', async () => {
const { service } = build();
const created = await service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'EXPORT',
withCustoms: true,
});
expect(created.code).toBe('BULK_EXPORT_STEEL_CUSTOMS');
expect(created.tradeDirection).toBe('EXPORT');
expect(created.withCustoms).toBe(true);
expect(created.documentTitle).toBe(
'Steel Transportation and Customs Clearance Services',
);
});
it('stores a null customs flag for intercity', async () => {
const { service } = build();
const created = await service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'INTERCITY',
});
expect(created.code).toBe('BULK_INTERCITY_STEEL');
expect(created.withCustoms).toBeNull();
expect(created.documentTitle).toBe('Steel Transportation Services');
});
it('rejects a customs flag on intercity', async () => {
const { service } = build();
await expect(
service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'INTERCITY',
withCustoms: false,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('requires a customs flag on import and export', async () => {
const { service } = build();
await expect(
service.create({ cargoTypeId: 'cargo-1', tradeDirection: 'IMPORT' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
const { repository, service } = build();
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
'cargo-1',
'INTERCITY',
null,
);
});
it('resolves an import bulk contract on direction and customs', async () => {
const { repository, service } = build();
await service.findActiveForContract('IMPORT', 'BULK', false, 'cargo-1');
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
'cargo-1',
'IMPORT',
false,
);
});
});

View File

@@ -61,7 +61,7 @@ export class ContractTemplatesController {
])
@ApiOperation({
summary:
"Create a bulk contract template for a (cargo type, customs option) pair",
"Create a bulk contract template for a (cargo type, trade direction, customs option) combination",
})
create(@Body() dto: CreateContractTemplateDto) {
return this.service.create(dto);

View File

@@ -1,10 +1,13 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { IsNull, Repository } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContractTemplate } from "./entities/contract-template.entity";
import {
BulkTemplateDirection,
ContractTemplate,
} from "./entities/contract-template.entity";
@Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
@@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
findByCargoCombo(
cargoTypeId: string,
withCustoms: boolean,
tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
return this.repository.findOne({
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
});
}
/**
* The active bulk template covering this cargo type: written against the
* cargo type itself or against its parent group (the two are mutually
* exclusive, so at most one row matches).
* exclusive, so at most one row matches). Intercity templates carry no
* customs variant, so they are matched on a null flag.
*/
findActiveBulkTemplate(
cargoTypeId: string,
withCustoms: boolean,
tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
): Promise<ContractTemplate | null> {
return this.repository
.createQueryBuilder("t")
.where("t.is_active = true")
.andWhere("t.with_customs = :withCustoms", { withCustoms })
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
.andWhere(
withCustoms === null
? "t.with_customs IS NULL"
: "t.with_customs = :withCustoms",
withCustoms === null ? {} : { withCustoms },
)
.andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c

View File

@@ -23,6 +23,9 @@ import {
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
import {
BulkTemplateDirection,
bulkTemplateCode,
bulkTemplateDirectionFor,
CONTRACT_TEMPLATE_CODES,
ContractTemplate,
ContractTemplateArticle,
@@ -77,10 +80,13 @@ export class ContractTemplatesService {
}
/**
* Staff-created bulk template for one (cargo type, customs option) pair.
* The cargo type must have hasContractTemplate enabled and the combination
* must not already exist — the same commodity + customs pairing is edited,
* never duplicated.
* Staff-created bulk template for one (cargo type, direction, customs
* option) triple. The cargo type must have hasContractTemplate enabled and
* the combination must not already exist — the same commodity + direction +
* customs pairing is edited, never duplicated.
*
* Intercity is domestic and crosses no border, so it carries no customs
* variant: the flag must be omitted and is stored as null.
*/
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
@@ -92,31 +98,46 @@ export class ContractTemplatesService {
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
);
}
const variant = dto.withCustoms ? "with" : "without";
const direction = dto.tradeDirection;
const intercity = direction === "INTERCITY";
if (intercity && dto.withCustoms !== undefined) {
throw new BadRequestException(
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
);
}
if (!intercity && dto.withCustoms === undefined) {
throw new BadRequestException(
`A ${direction.toLowerCase()} template must state whether customs clearing is included`,
);
}
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
const existing = await this.repository.findByCargoCombo(
dto.cargoTypeId,
dto.withCustoms,
direction,
withCustoms,
);
if (existing) {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
`${label} already exists — edit that template instead`,
);
}
const template = new ContractTemplate();
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
template.name =
dto.name ??
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
template.name = dto.name ?? label;
template.description = dto.description ?? null;
template.documentTitle = dto.withCustoms
template.documentTitle = withCustoms
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation Services`;
template.whereasClauses = [];
template.articles = [];
template.isActive = true;
template.cargoTypeId = cargoType.id;
template.withCustoms = dto.withCustoms;
template.tradeDirection = direction;
template.withCustoms = withCustoms;
template.isSystem = false;
try {
return await this.repository.saveTemplate(template);
@@ -124,13 +145,29 @@ export class ContractTemplatesService {
// Partial unique index backstop for concurrent creates of the same combo.
if ((error as { code?: string })?.code === "23505") {
throw new ConflictException(
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
`${label} already exists — edit that template instead`,
);
}
throw error;
}
}
/** Human label for one bulk combination, used for names and conflict errors. */
private comboLabel(
cargoTypeName: string,
direction: BulkTemplateDirection,
withCustoms: boolean | null,
): string {
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
const customs =
withCustoms === null
? ""
: withCustoms
? ", with customs clearing"
: ", without customs clearing";
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
}
/** Bulk templates only — the five seeded container templates are permanent. */
async remove(code: string): Promise<void> {
const template = await this.getByCode(code);
@@ -146,9 +183,10 @@ export class ContractTemplatesService {
* The active template used when generating a contract document. Container
* contracts resolve through the fixed direction/customs codes; bulk contracts
* resolve through the staff-created template for the contract's cargo type
* (or its parent group) and customs option. Null when nothing matches or the
* match is deactivated (the renderer then falls back to the built-in generic
* layout).
* (or its parent group), trade direction and customs option. A domestic
* contract resolves to the intercity template regardless of its customs flag.
* Null when nothing matches or the match is deactivated (the renderer then
* falls back to the built-in generic layout).
*/
async findActiveForContract(
tradeDirection?: string | null,
@@ -159,9 +197,11 @@ export class ContractTemplatesService {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
if (!cargoTypeId) return null;
const direction = bulkTemplateDirectionFor(tradeDirection);
return this.repository.findActiveBulkTemplate(
cargoTypeId,
Boolean(customsClearingEnabled),
direction,
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
);
}
const code = contractTemplateCodeFor(
@@ -274,18 +314,31 @@ export class ContractTemplatesService {
/**
* Registry key the mock preview renders against. Staff-created bulk
* templates aren't in the fixed code map — they preview against the
* representative bulk import pack matching their customs option.
* templates aren't in the fixed code map — they preview against the bulk
* pack matching their own direction and customs option.
*/
private previewKeyFor(template: ContractTemplate): string {
if (template.cargoTypeId) {
return template.withCustoms
? "IMP_BULK_USD_FORWARDING"
: "IMP_BULK_USD_TRANSPORT_ONLY";
const direction = bulkTemplateDirectionFor(template.tradeDirection);
const dir =
direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM";
const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY";
return `${dir}_BULK_USD_${scope}`;
}
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
}
/**
* Preview direction: bulk templates carry it on the row, the fixed container
* codes carry it as the code prefix.
*/
private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection {
if (template.cargoTypeId) {
return bulkTemplateDirectionFor(template.tradeDirection);
}
return bulkTemplateDirectionFor(template.code.split("_")[0]);
}
private buildMockView(
template: ContractTemplate,
dynamicTemplate: ContractDynamicTemplateView,
@@ -294,11 +347,12 @@ export class ContractTemplatesService {
const previewKey = this.previewKeyFor(template);
const meta = getTemplateMeta(previewKey);
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
const direction = this.previewDirectionFor(template);
const now = new Date();
// Representative rate schedule so the admin preview shows the live-rate
// table shape. Real contracts populate this from freight.rates (LIVE).
const rateSchedule = this.mockRateSchedule(code, isBulk);
const rateSchedule = this.mockRateSchedule(direction, isBulk);
return {
bookingId: "00000000-0000-0000-0000-000000000000",
@@ -336,11 +390,7 @@ export class ContractTemplatesService {
schedule: {
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
tradeDirection: code.startsWith("IMPORT")
? "IMPORT"
: code.startsWith("EXPORT")
? "EXPORT"
: "DOMESTIC",
tradeDirection: direction === "INTERCITY" ? "DOMESTIC" : direction,
freightType: isBulk ? "BULK" : "CONTAINER",
serviceType: "Rail transport and customs clearance",
scheduledDate: "—",
@@ -379,16 +429,14 @@ export class ContractTemplatesService {
}
/** Static, representative rate schedule for the admin preview only. */
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
const dir = code.startsWith("IMPORT")
? "import"
: code.startsWith("EXPORT")
? "export"
: "domestic";
private mockRateSchedule(
direction: BulkTemplateDirection,
isBulk: boolean,
): RateSchedule {
const lane =
dir === "export"
direction === "EXPORT"
? "Galaan Multipurpose Port → SGTD"
: dir === "domestic"
: direction === "INTERCITY"
? "Mojo Dry Port → Dire Dawa"
: "Negad → Mojo Dry Port";

View File

@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
@@ -13,6 +14,11 @@ import {
ValidateNested,
} from "class-validator";
import {
BULK_TEMPLATE_DIRECTIONS,
BulkTemplateDirection,
} from "../entities/contract-template.entity";
export class CreateContractTemplateDto {
@ApiProperty({
description:
@@ -23,10 +29,19 @@ export class CreateContractTemplateDto {
cargoTypeId!: string;
@ApiProperty({
description: "Whether this is the with-customs-clearing variant",
description: "Trade direction this template is written for",
enum: BULK_TEMPLATE_DIRECTIONS,
})
@IsIn(BULK_TEMPLATE_DIRECTIONS as unknown as string[])
tradeDirection!: BulkTemplateDirection;
@ApiPropertyOptional({
description:
"Whether this is the with-customs-clearing variant. Required for IMPORT/EXPORT, rejected for INTERCITY (domestic movements cross no border)",
})
@IsOptional()
@IsBoolean()
withCustoms!: boolean;
withCustoms?: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional()

View File

@@ -9,10 +9,10 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
* template). These are system rows: always present, never deletable.
*
* Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
* The retired direction-keyed bulk codes remain listed so old frozen document
* snapshots still label correctly.
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
* (`withCustoms`), one template per combination. Their codes are generated by
* `bulkTemplateCode` below. The retired direction-keyed bulk codes remain
* listed so old frozen document snapshots still label correctly.
*
* Contracts store DOMESTIC for intercity movements; the template layer labels
* those INTERCITY to match the commercial vocabulary used on the printed
@@ -82,9 +82,41 @@ export function contractTemplateCodeFor(
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
}
/** The three directions a bulk template can be written for. */
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
/**
* Contracts store DOMESTIC for intercity movements; templates use INTERCITY.
* Anything that is not an explicit IMPORT/EXPORT is domestic, matching
* `contractTemplateCodeFor`.
*/
export function bulkTemplateDirectionFor(
tradeDirection?: string | null,
): BulkTemplateDirection {
const value = (tradeDirection ?? "").toUpperCase();
return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY";
}
/**
* Generated code for a staff-created bulk template. Intercity gets no customs
* suffix — it crosses no border, so the variant does not exist.
*/
export function bulkTemplateCode(
cargoCode: string,
direction: BulkTemplateDirection,
withCustoms: boolean | null,
): string {
const suffix =
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
}
@Entity({ schema: "freight", name: "contract_templates" })
// Uniqueness lives in partial DB indexes (live rows only): code, and
// (cargo_type_id, with_customs) for staff-created bulk templates.
// (cargo_type_id, trade_direction, coalesce(with_customs,false)) for
// staff-created bulk templates.
@Index(["code"])
export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 80 })
@@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity {
@JoinColumn({ name: "cargo_type_id" })
cargoType?: CargoType | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
@Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true })
tradeDirection?: BulkTemplateDirection | null;
/**
* Bulk templates only: whether this is the with-customs-clearing variant.
* Always null for INTERCITY templates — domestic movements have no customs
* leg, so neither variant applies.
*/
@Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null;

View File

@@ -157,13 +157,9 @@ export class ContractBookingService {
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
@@ -441,12 +437,9 @@ export class ContractBookingService {
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
@@ -545,9 +538,7 @@ export class ContractBookingService {
'Shipment-request initiation applies only to general customs contracts.',
);
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
await this.assertNotExpired(contract);
const route = await this.resolveRoute(contract, opts.contractRouteId);
@@ -681,9 +672,11 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
// already checked expiry at start). Finishing an in-flight booking must
// proceed even if the contract expires meanwhile — only starting a NEW
// booking is blocked (see assertNotExpired).
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a direct one-time booking passes at create.
@@ -1019,6 +1012,22 @@ export class ContractBookingService {
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
/**
* Blocks starting a NEW booking (create/initiate) once the contract has
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
* for the nightly sweep. Only for the "start something new" entry points —
* a booking already underway (completeUnderContract) must be allowed to
* finish even if the contract expires mid-flight.
*/
private async assertNotExpired(contract: Contract): Promise<void> {
if (!isEffectivelyExpired(contract)) return;
if (contract.status !== 'EXPIRED') {
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
if (flipped) contract.status = 'EXPIRED';
}
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
private async assertGate(
contract: Contract,
isGlActor: boolean,

View File

@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
export interface ContractUnitRateLineItem {
code: string;
label: string;
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
unit:
| 'per_container'
| 'per_wagon'
| 'per_ton'
| 'per_item'
| 'per_km'
| 'per_liter'
| 'flat';
unitPrice: number;
containerSize?: string | null;
conditionalOn?: string | null;
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
return 'per_wagon';
case 'PER_CONTAINER':
return 'per_container';
case 'PER_LITER':
return 'per_liter';
default:
return 'flat';
}
@@ -276,6 +285,42 @@ export class ContractPricingService {
}
}
// Fuel surcharge — shown when the contract's commodity incurs fuel
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
// this line freezes the agreed unit price.
{
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (scope?.cargoType?.hasFuel && route) {
const fuel = liveRates.find(
(r) =>
r.trigger === 'FUEL' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId &&
r.cargoTypeId === scope.cargoTypeId,
);
if (fuel && Number(fuel.rateValue) > 0) {
// Per-liter collapses to one flat total (base liters × rate value) —
// the customer only sees the final price, and booking pricing bills
// the same flat figure once (see RuleEngineService.fuelCharges).
const perLiter = fuel.rateUnit === 'PER_LITER';
const total = perLiter
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
: Number(fuel.rateValue);
lineItems.push({
code: 'FUEL_SURCHARGE',
label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
unitPrice: convert(total),
cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_fuel',
});
}
}
}
// Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if (

View File

@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
): Promise<void> {
const role = dto.role as ContractSignerRole;
// Both sign() and counterSign() land here — who signed what, and whether the
// ink came from the request or the signer's saved profile signature.
logCtx(
{
contractId: contract.id,
reference: contract.reference,
role,
signerUserId: options.signerUserId,
usedDrawnImage: Boolean(dto.signatureImageBase64),
},
{ path: "contractSignatures", mode: "push" },
);
// Resolve the signature image. The client may send a freshly-drawn image, or
// omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied.

View File

@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
return result.affected ?? 0;
}
/**
* Same-row version of expireLapsedContracts, for lazy flips on read/booking
* paths — flips this one contract to EXPIRED if it's lapsed and not already
* terminal. No-op (returns false) if the contract isn't actually lapsed, so
* callers can call this unconditionally without a pre-check.
*/
async expireIfLapsed(id: string): Promise<boolean> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
.where('id = :id', { id })
.andWhere('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
now: new Date(),
})
.execute();
return (result.affected ?? 0) > 0;
}
/**
* Live contracts whose validity ends between `days` and `days + 1` days from
* now — the slice the daily expiry-reminder cron warns about. The window is

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import { YardCountry } from '@edr/types';
//
@@ -811,6 +811,27 @@ export class ContractsService {
throw new NotFoundException(`Contract ${id} not found`);
}
// Lazy expiry flip: the nightly cron only sweeps once a day, so a
// contract can be past contract_valid_until for hours before it shows
// EXPIRED. Flip it here so the detail page never shows a stale status.
if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') {
const flipped = await this.contractsRepository.expireIfLapsed(id);
if (flipped) {
contract.status = 'EXPIRED';
}
}
// Entry state for every contract flow (submit, approve, sign, suspend…) —
// see the equivalent in BookingsService.findById.
logCtx(
{
id: contract.id,
reference: contract.reference,
statusAtEntry: contract.status,
companyId: contract.companyId,
},
{ path: "contract" },
);
if (contract.files && contract.files.length > 0) {
contract.files = await Promise.all(
contract.files.map(async (file: FileRecord) => {

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, Length } from "class-validator";
/** `POST /v1/cancel` body — `ReasonCode`/`Remark` are the collection's own field names. */
export class CancelEimsRegistrationDto {
@ApiProperty({
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
example: "1",
})
@IsString()
@Length(1, 8)
reasonCode!: string;
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
@IsOptional()
@IsString()
@Length(0, 500)
remark?: string;
}

View File

@@ -0,0 +1,88 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
/**
* `POST /v1/receipt/sales` — invoice/amounts/currency/IRN are derived from the invoice; everything
* here is what this codebase has no source of truth for, so it is asked of the caller rather than
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
*/
export class RegisterSalesReceiptDto {
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
@IsIn(EIMS_MODE_OF_PAYMENT)
modeOfPayment!: EimsModeOfPayment;
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ description: "Overrides the amount collected; defaults to the invoice's paidAmount." })
@IsOptional()
@IsNumber()
collectedAmount?: number;
@ApiPropertyOptional({ description: "ETB, USD or CAD. Defaults to the invoice's currency." })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: "Required when currency is not ETB." })
@IsOptional()
@IsNumber()
exchangeRate?: number;
@ApiPropertyOptional({ description: 'Defaults to "FULL" if the invoice balance is 0, else "PARTIAL".' })
@IsOptional()
@IsString()
paymentCoverage?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 200)
collectorName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 200)
paymentServiceProvider?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 200)
otherPaymentServiceProviderName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
accountNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
transactionNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
chequeNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
cpoNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Length(0, 100)
documentNumber?: string;
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
/**
* `POST /v1/receipt/withholding`. `type`/`preTaxAmount`/`withholdingAmount` are real money/tax
* values this codebase has no computed source for (the same "no tax model" gap the invoice mapper
* documents) — required from the caller rather than defaulted or derived.
*/
export class RegisterWithholdingReceiptDto {
@ApiProperty({
description: 'WithholdDetail.Type. Only "TWHT" has been observed; not restricted to it.',
example: "TWHT",
})
@IsString()
@Length(1, 16)
type!: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
rate?: number;
@ApiProperty({ description: "Amount the withholding is calculated against." })
@IsNumber()
preTaxAmount!: number;
@ApiProperty({ description: "The withheld amount itself." })
@IsNumber()
withholdingAmount!: number;
@ApiPropertyOptional({ description: 'Defaults to "Withholding".' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ description: "Required when the invoice currency is not ETB." })
@IsOptional()
@IsNumber()
exchangeRate?: number;
}

View File

@@ -10,9 +10,11 @@ export class ResolveEimsRegistrationDto {
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
})
// Not capped at 64: eims_irn is `text` now — a real IRN already overflowed a former
// varchar(64) (69 chars). This bound is a sanity ceiling, not a confirmed MoR format.
@IsOptional()
@IsString()
@Length(1, 64)
@Length(1, 500)
irn?: string;
@ApiPropertyOptional({

View File

@@ -0,0 +1,153 @@
import { BadRequestException } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsInvoiceStatus } from "./eims-registration.types";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
companyId: "company-1",
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: IRN,
eimsCancelledAt: null,
eimsCancellationDate: null,
eimsCancellationReasonCode: null,
eimsCancellationRemark: null,
...over,
}) as unknown as Invoice;
/** In-memory stand-in, same shape as the registration spec's FakeDb but with only what cancel needs. */
class FakeDb {
invoices = new Map<string, Invoice>();
companyContact: { phone: string | null; email: string | null } | null = null;
constructor(invoices: Invoice[]) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
}
private manager = {
createQueryBuilder: (entity: unknown) => {
let id: string | undefined;
const builder = {
setLock: () => builder,
where: (_clause: string, params: Record<string, string>) => {
id = params.invoiceId;
return builder;
},
getOne: async () => (entity === Invoice ? (this.invoices.get(id!) ?? null) : null),
};
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>) => {
Object.assign(this.invoices.get(id)!, patch);
},
};
asDataSource(): DataSource {
return {
manager: this.manager,
query: async () => (this.companyContact ? [this.companyContact] : []),
transaction: async (body: (m: unknown) => Promise<unknown>) => body(this.manager),
} as unknown as DataSource;
}
}
const build = (db: FakeDb, postBearer: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) =>
new EimsCancellationService(
db.asDataSource(),
{ postBearer } as unknown as EimsClientService,
{ directSend } as unknown as NotificationsService,
);
describe("EimsCancellationService.cancelInvoiceWithEims", () => {
it("cancels a registered invoice and persists MoR's confirmation", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockResolvedValue({ statusCode: 200, message: "Success", body: { cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" } });
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1", "Duplicate");
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "Duplicate" });
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(view.eimsCancellationDate).toBe("Sun Dec 22 21:55:03 EAT 2024");
expect(view.eimsCancellationReasonCode).toBe("1");
expect(view.eimsCancellationRemark).toBe("Duplicate");
expect(db.invoices.get(INVOICE_ID)?.eimsCancelledAt).toBeInstanceOf(Date);
});
it("defaults Remark to an empty string, matching the collection's request shape", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
});
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
const postBearer = jest.fn();
const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(postBearer).not.toHaveBeenCalled();
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
it("refuses to cancel an invoice that was never registered", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null })]);
const postBearer = jest.fn();
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
BadRequestException,
);
expect(postBearer).not.toHaveBeenCalled();
});
it("propagates a MoR rejection and leaves the invoice REGISTERED", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS cancel failed (406)", 406));
await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
});
it("notifies the buyer company on success, without blocking the result", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("cancelled"));
});
it("does not fail cancellation when the buyer notification itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { cancellationDate: "x" } });
const view = await build(db, postBearer, directSend).cancelInvoiceWithEims(INVOICE_ID, "1");
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
});

View File

@@ -0,0 +1,117 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager } from "typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsClientService } from "./eims-client.service";
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
/**
* `POST /v1/cancel` for one already-registered invoice.
*
* Simpler than registration: cancellation carries no `InvoiceCounter`/`DocumentNumber`, so none of
* `EimsSystemState`'s reservation machinery applies, and — unlike registration — a retried cancel
* is safe: the collection's bulk-cancel example shows MoR itself rejects a second cancel with
* "IRN already Canceled.", so there is no double-filing risk the way an unacknowledged register
* call has. That is what makes the simpler shape below correct: no system-wide block, no in-flight
* marker, just a lock-check-unlock before the call and a fresh lock-check-write after it.
*
* ponytail: the eligibility check (TX1) and the write (TX2) are not one atomic operation, so two
* concurrent cancels on the same invoice could both pass TX1 and both call MoR — wasted, but safe,
* per the paragraph above. Upgrade to a single locked reservation (like registration's) only if
* MoR's cancel endpoint turns out not to be idempotent after all.
*
* Bearer-authenticated but unsigned (`postBearer`), same as `/v1/verify` — the collection's saved
* `/v1/cancel` request carries no `{request,signature,certificate}` envelope.
*/
@Injectable()
export class EimsCancellationService {
private readonly logger = new Logger(EimsCancellationService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly client: EimsClientService,
private readonly notifications: NotificationsService,
) {}
/**
* Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice
* that was never registered — there is no IRN to cancel.
*/
async cancelInvoiceWithEims(
invoiceId: string,
reasonCode: string,
remark?: string,
): Promise<EimsInvoiceStatusView> {
const eligible = await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null;
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
});
}
return invoice;
});
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
// Outside any transaction — no DB lock is held across the wire.
const response = await this.client.postBearer<EimsCancelRequest, EimsCancelResponse>(
"/v1/cancel",
request,
);
const cancellationDate = response?.body?.cancellationDate ?? null;
await this.dataSource.transaction(async (manager) => {
const invoice = await this.lockInvoice(manager, invoiceId);
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Cancelled,
eimsCancelledAt: new Date(),
eimsCancellationDate: cancellationDate,
eimsCancellationReasonCode: reasonCode,
eimsCancellationRemark: remark ?? null,
});
});
this.logger.log(`Invoice ${eligible.invoiceNumber} cancelled with EIMS (IRN ${eligible.eimsIrn})`);
await this.notifyBuyer(eligible);
return this.getEimsCancellationStatus(invoiceId);
}
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
return toEimsInvoiceStatusView(invoice);
}
/** Best-effort — a notification failure must never mask a cancellation that already succeeded. */
private async notifyBuyer(invoice: Invoice): Promise<void> {
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
`Invoice ${invoice.invoiceNumber} has been cancelled with MoR EIMS.`,
);
} catch (err) {
this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
}
}
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;
}
}

View File

@@ -0,0 +1,118 @@
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import { eimsConfig } from "./eims-test-fixtures";
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
describe("assertEimsInvoiceConfig — charge-type overrides", () => {
it("passes when EIMS_TAX_CODE_BY_CHARGE_TYPE and _RATE_ list the same charge types", () => {
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "0" },
},
}),
),
).not.toThrow();
});
it("throws when a charge type has a code but no rate, or a rate but no code", () => {
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: {},
},
}),
),
).toThrow(/mismatched: RAIL_FREIGHT/);
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: {},
taxRateByChargeType: { RAIL_FREIGHT: "0" },
},
}),
),
).toThrow(/mismatched: RAIL_FREIGHT/);
});
it("throws when a rate/excise/discount override is not a number", () => {
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "not-a-number" },
},
}),
),
).toThrow(/EIMS_TAX_RATE_BY_CHARGE_TYPE\[RAIL_FREIGHT\]/);
expect(() =>
assertEimsInvoiceConfig(
eimsConfig({ invoice: { ...eimsConfig().invoice, discountByChargeType: { X: "abc" } } }),
),
).toThrow(/EIMS_DISCOUNT_BY_CHARGE_TYPE\[X\]/);
});
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {
const context = buildEimsContext(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCode: "VAT15",
taxRatePercent: 15,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "0" },
exciseByChargeType: { RAIL_FREIGHT: "5" },
discountByChargeType: { RAIL_FREIGHT: "10" },
},
}),
input,
);
expect(context.taxForLine(line("RAIL_FREIGHT"), 1)).toEqual({
code: "VAT0",
ratePercent: 0,
exciseTaxValue: 5,
discount: 10,
});
});
it("falls back to the invoice-wide default for a charge type with no override", () => {
const context = buildEimsContext(
eimsConfig({
invoice: {
...eimsConfig().invoice,
taxCode: "VAT15",
taxRatePercent: 15,
exciseTaxValue: 0,
taxCodeByChargeType: { RAIL_FREIGHT: "VAT0" },
taxRateByChargeType: { RAIL_FREIGHT: "0" },
},
}),
input,
);
expect(context.taxForLine(line("HANDLING"), 1)).toEqual({
code: "VAT15",
ratePercent: 15,
exciseTaxValue: 0,
discount: 0,
});
});
});

View File

@@ -59,6 +59,47 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
}
assertSellerFormats(config.invoice);
assertChargeTypeOverrides(config.invoice);
}
/**
* Per-`chargeType` tax overrides must be internally consistent before anything is filed:
* `EIMS_TAX_CODE_BY_CHARGE_TYPE` and `EIMS_TAX_RATE_BY_CHARGE_TYPE` must name the same charge
* types (a code with no rate, or vice versa, is a half-finished override), and every rate/excise/
* discount value must parse as a number — checked once here rather than once per line at
* registration time.
*/
function assertChargeTypeOverrides(invoice: EimsConfig["invoice"]): void {
const codeKeys = Object.keys(invoice.taxCodeByChargeType);
const rateKeys = Object.keys(invoice.taxRateByChargeType);
const mismatched = [...new Set([...codeKeys, ...rateKeys])].filter(
(k) => !(codeKeys.includes(k) && rateKeys.includes(k)),
);
if (mismatched.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message:
`EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge ` +
`types; mismatched: ${mismatched.join(", ")}`,
});
}
const numericMaps: { env: string; map: Record<string, string> }[] = [
{ env: "EIMS_TAX_RATE_BY_CHARGE_TYPE", map: invoice.taxRateByChargeType },
{ env: "EIMS_EXCISE_BY_CHARGE_TYPE", map: invoice.exciseByChargeType },
{ env: "EIMS_DISCOUNT_BY_CHARGE_TYPE", map: invoice.discountByChargeType },
];
const badNumbers = numericMaps.flatMap(({ env, map }) =>
Object.entries(map)
.filter(([, value]) => !Number.isFinite(Number(value)))
.map(([chargeType]) => `${env}[${chargeType}]`),
);
if (badNumbers.length > 0) {
throw new BadRequestException({
code: "EIMS_INVOICE_CONFIG_INVALID",
message: `EIMS charge-type overrides must be numbers: ${badNumbers.join(", ")}`,
});
}
}
/**
@@ -135,9 +176,25 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
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 }),
// Per-`chargeType` override when one is configured (validated symmetric in
// assertChargeTypeOverrides), else the single invoice-wide default.
taxForLine: (line: EimsMapperLine) => {
const { chargeType } = line;
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
const rate =
chargeType in invoice.taxRateByChargeType
? Number(invoice.taxRateByChargeType[chargeType])
: ratePercent;
const excise =
chargeType in invoice.exciseByChargeType
? Number(invoice.exciseByChargeType[chargeType])
: exciseTaxValue;
const discount =
chargeType in invoice.discountByChargeType
? Number(invoice.discountByChargeType[chargeType])
: 0;
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
},
natureOfSupplies: invoice.natureOfSupplies,
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
buyerCountryCode: invoice.buyerCountryCode,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -7,6 +7,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -85,6 +86,8 @@ class FakeDb {
state: EimsSystemState | null = null;
/** Runs before every transaction body, to simulate a concurrent writer. */
onTransaction: (() => void) | null = null;
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
companyContact: { phone: string | null; email: string | null } | null = null;
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
@@ -133,10 +136,13 @@ class FakeDb {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async (sql: string) =>
sql.includes("eims_system_state")
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
: LINES,
query: async (sql: string) => {
if (sql.includes("eims_system_state")) {
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
}
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
return LINES;
},
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
@@ -155,6 +161,7 @@ const build = (
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
@@ -164,6 +171,7 @@ const build = (
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService,
);
/**
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
},
});
const okResponse = (irn = IRN) =>
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "SUCCESS",
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
});
const apiError = (kind: string, status?: number) =>
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
@@ -226,6 +237,51 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("persists signedQR alongside the IRN", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsSignedQr).toBe("signed-qr-payload");
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
});
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsSignedQr).toBeNull();
});
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
.registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
});
it("does not fail registration when the buyer notification itself fails", async () => {
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
.registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
});
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());
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// The two numbers move differently, because MoR constrains them differently: the counter must
// not skip (it returns), the document number must not repeat (it is burned).
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5");
expect(second.DocumentDetails.DocumentNumber).toBe("6");
expect(second.DocumentDetails.DocumentNumber).toBe("5");
});
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockResolvedValue(okResponse());
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
// that actually failed live (eims_irn too narrow for the real value MoR returned).
let call = 0;
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
const realUpdate = manager.update;
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
call++;
if (call === 3) throw new Error("value too long for type character varying(64)");
return realUpdate(entity, id, patch);
});
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/value too long/);
// The IRN is never lost, even though the normal success path never committed.
expect(db.state?.blockedReason).toContain(IRN);
expect(db.state?.blockedReason).toContain("ACCEPTED");
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.body).toContain(IRN);
});
});

View File

@@ -19,6 +19,9 @@ import {
} from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
private get cfg(): EimsConfig {
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
let irn: string;
let ackDate: string | undefined;
let signedQR: 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;
signedQR = result.signedQR;
} catch (err) {
await this.settleFailure(invoiceId, reservation, err);
throw err;
}
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
try {
await this.settleSuccess(invoiceId, reservation, irn, ackDate, signedQR);
} catch (err) {
// MoR already accepted this document — unlike settleFailure's targets, this is not
// ambiguous, it is a *known* IRN we simply failed to persist (confirmed live 2026-08-12: a
// column too narrow for a real IRN). Losing it here would be worse than the persistence
// bug itself, so it goes straight into the block reason and the alert, not just a log line.
await this.blockOnKnownIrnPersistFailure(invoiceId, reservation, irn, ackDate, err);
throw err;
}
this.logger.log(
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
);
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
reservation: Reservation,
irn: string,
ackDate?: string,
signedQR?: string,
): Promise<void> {
let companyId: string | undefined;
let invoiceNumber = invoiceId;
await this.dataSource.transaction(async (manager) => {
await this.lockInvoice(manager, invoiceId);
const invoice = await this.lockInvoice(manager, invoiceId);
companyId = invoice.companyId;
invoiceNumber = invoice.invoiceNumber;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn,
eimsAckDate: ackDate ?? null,
eimsSignedQr: signedQR ?? null,
eimsLastError: null,
});
await manager.update(EimsSystemState, reservation.stateId, {
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
blockedReason: null,
});
});
// Best-effort, outside the transaction: MoR checklist ADD-N001 wants the buyer notified of a
// registration event. Never lets a notification failure mask a filing that already succeeded.
if (companyId) {
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
companyId,
`Invoice ${invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
);
} catch (err) {
this.logger.warn(`EIMS buyer notification failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
}
/**
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* TX2b. A deterministic rejection releases the reservation **and returns both numbers**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* The two numbers move differently, because MoR constrains them differently:
* Both `InvoiceCounter` and `DocumentNumber` roll back together on a deterministic rejection —
* MoR's own expected-next-value only advances on acceptance, for both fields:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
* not unique" rejection; that turned out to describe the same constraint from the other side
* (MoR rejects both reuse *and* skipping ahead of its true next value), and burning forward on
* every rejection permanently drifted past what MoR would ever accept again — confirmed live
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
*
* An ambiguous result keeps both: MoR may have counted and stored the document.
*/
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
reservation.stateId,
deterministic
? {
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
// Both return: MoR never counted a refused document against either sequence.
nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* MoR accepted the document (a real IRN came back) but recording that locally failed — the
* reservation is still held from TX1, so the system-wide block goes on regardless of *why*
* `settleSuccess` failed. The IRN and ack date are written straight into `blockedReason` so a
* human resolving this never has to dig through logs for the one thing that must not be lost.
*/
private async blockOnKnownIrnPersistFailure(
invoiceId: string,
reservation: Reservation,
irn: string,
ackDate: string | undefined,
err: unknown,
): Promise<void> {
const reason =
`Invoice ${invoiceId} was ACCEPTED by EIMS (IRN ${irn}${ackDate ? `, ack ${ackDate}` : ""}) ` +
`but recording it locally failed: ${(err as Error)?.message ?? "unknown error"}. Resolve with ` +
`POST /invoices/${invoiceId}/eims/resolve using this IRN once the underlying issue is fixed — ` +
"do not resubmit, the document already exists at MoR.";
try {
await this.dataSource.manager.update(EimsSystemState, reservation.stateId, { blockedReason: reason });
} catch (updateErr) {
// Even the block itself failed to write — last resort is the log, since there is nothing
// left to retry into.
this.logger.error(`Could not record EIMS block for invoice ${invoiceId}: ${reason}`, updateErr as Error);
return;
}
this.logger.error(reason);
// Not alertStaff(): its canned copy for a non-deterministic result always says "the IRN is
// unknown", which is false here — the whole point of this path is that the IRN *is* known.
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: NotificationPriority.HIGH,
title: "EIMS accepted an invoice but it was not recorded — all further filing is blocked",
body: reason,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: EimsInvoiceStatus.Unknown, irn, action: "EIMS_PERSIST_FAILED" },
});
} catch (notifyErr) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(notifyErr as Error).message}`);
}
}
/**
* Tell the people who can act about a failed filing.
*
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
// ── 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 }> {
private async submit(
request: EimsInvoiceRequest,
): Promise<{ irn: string; ackDate?: string; signedQR?: string }> {
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
"/v1/register",
request,
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
response?.statusCode,
);
}
return { irn, ackDate: response.body?.ackDate };
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
}
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
}
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,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
};
return toEimsInvoiceStatusView(invoice);
}
}

View File

@@ -0,0 +1,27 @@
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
/**
* `Invoice` → `EimsInvoiceStatusView`, shared by every service that mutates EIMS state on an
* invoice (registration, cancellation, receipts) — one place defines what "the current EIMS
* status of an invoice" means to a caller.
*/
export function toEimsInvoiceStatusView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter;
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
eimsSignedQr: invoice.eimsSignedQr ?? null,
eimsCancelledAt: invoice.eimsCancelledAt ?? null,
eimsCancellationDate: invoice.eimsCancellationDate ?? null,
eimsCancellationReasonCode: invoice.eimsCancellationReasonCode ?? null,
eimsCancellationRemark: invoice.eimsCancellationRemark ?? null,
};
}

View File

@@ -3,8 +3,13 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
/**
* Manual EIMS actions on an existing invoice.
@@ -13,10 +18,12 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
* 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`.
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` 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, cancelling is its own irreversible-at-MoR action, and filing a receipt is a third —
* none follows from the right to register. Only `eims/status` and `eims/receipts` ride 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.
@@ -27,7 +34,11 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
@ApiBearerAuth()
@Controller("invoices")
export class EimsInvoiceController {
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
constructor(
private readonly registration: EimsInvoiceRegistrationService,
private readonly cancellation: EimsCancellationService,
private readonly receipts: EimsReceiptService,
) {}
@Post(":id/eims/register")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
status(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.getEimsStatus(id);
}
@Post(":id/eims/cancel")
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
})
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
}
@Post(":id/eims/receipt/sales")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
registerSalesReceipt(@Param("id", ParseUUIDPipe) id: string, @Body() dto: RegisterSalesReceiptDto) {
return this.receipts.registerSalesReceipt(id, dto);
}
@Post(":id/eims/receipt/withholding")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a withholding receipt with MoR EIMS against a registered invoice" })
registerWithholdingReceipt(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RegisterWithholdingReceiptDto,
) {
return this.receipts.registerWithholdingReceipt(id, dto);
}
@Get(":id/eims/receipts")
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiOperation({ summary: "List every EIMS receipt filed against this invoice, newest first" })
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
return this.receipts.listReceipts(id);
}
}

View File

@@ -0,0 +1,231 @@
import { BadRequestException } 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 { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { EimsReceiptService } from "./eims-receipt.service";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const SESSION = { systemNumber: "B0360154BA", systemType: "SYS" };
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
companyId: "company-1",
currency: "ETB",
totalAmount: "10000.00",
paidAmount: "10000.00",
balanceAmount: "0.00",
eimsIrn: IRN,
...over,
}) as unknown as Invoice;
/** In-memory stand-in: Invoice lookups + EimsReceipt save/update/find. */
class FakeDb {
invoices = new Map<string, Invoice>();
receipts = new Map<string, Record<string, unknown>>();
companyContact: { phone: string | null; email: string | null } | null = null;
private seq = 0;
constructor(invoices: Invoice[]) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
}
private manager = {
findOne: async (entity: unknown, options: { where: { id: string } }) =>
entity === Invoice
? (this.invoices.get(options.where.id) ?? null)
: (this.receipts.get(options.where.id) ?? null),
find: async (_entity: unknown, options: { where: { invoiceId: string } }) =>
[...this.receipts.values()].filter((r) => r.invoiceId === options.where.invoiceId),
save: async (_entity: unknown, data: Record<string, unknown>) => {
const id = `receipt-${++this.seq}`;
const row = { id, ...data };
this.receipts.set(id, row);
return row;
},
update: async (_entity: unknown, id: string, patch: Record<string, unknown>) => {
Object.assign(this.receipts.get(id)!, patch);
},
};
asDataSource(): DataSource {
return {
manager: this.manager,
query: async () => (this.companyContact ? [this.companyContact] : []),
} as unknown as DataSource;
}
}
const config = (): EimsConfig => ({ tin: "0053481357" }) as EimsConfig;
const build = (
db: FakeDb,
postBearer: jest.Mock,
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsReceiptService(
db.asDataSource(),
{ get: () => config() } as unknown as ConfigService,
{ postBearer } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ directSend } as unknown as NotificationsService,
);
const okResponse = (over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "Success",
body: { status: "A", rrn: "rrn-value", qr: "iVBORw0KGgo...", ...over },
});
describe("EimsReceiptService.registerSalesReceipt", () => {
it("registers a sales receipt and persists the RRN/QR", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const receipt = await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
} as never);
expect(postBearer).toHaveBeenCalledTimes(1);
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/sales");
const request = postBearer.mock.calls[0][1];
expect(request.SellerTIN).toBe("0053481357");
expect(request.SourceSystemNumber).toBe("B0360154BA");
expect(request.Invoices[0].InvoiceIRN).toBe(IRN);
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
expect(receipt.rrn).toBe("rrn-value");
expect(receipt.qr).toBe("iVBORw0KGgo...");
});
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
expect(postBearer.mock.calls[0][1].Invoices[0].PaymentCoverage).toBe("PARTIAL");
});
it("rejects an unconfirmed receipt currency locally, with zero HTTP calls", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn();
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
currency: "GBP",
} as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(postBearer).not.toHaveBeenCalled();
});
it("refuses to file a receipt against an unregistered invoice", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: null })]);
const postBearer = jest.fn();
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(postBearer).not.toHaveBeenCalled();
});
it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406));
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
expect(receipt.status).toBe(EimsReceiptStatus.Failed);
});
it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "EIMS receipt timed out"));
await expect(
build(db, postBearer).registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never),
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
expect(receipt.status).toBe(EimsReceiptStatus.Unknown);
});
it("notifies the buyer company on success, without blocking the result", async () => {
const db = new FakeDb([invoiceRow()]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const receipt = await build(db, postBearer, directSend).registerSalesReceipt(INVOICE_ID, {
modeOfPayment: "CASH",
} as never);
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining("sales"));
});
});
describe("EimsReceiptService.registerWithholdingReceipt", () => {
it("registers a withholding receipt and persists the RRN", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const receipt = await build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 6000,
withholdingAmount: 120,
} as never);
expect(postBearer.mock.calls[0][0]).toBe("/v1/receipt/withholding");
const request = postBearer.mock.calls[0][1];
expect(request.InvoiceDetail.InvoiceIRN).toBe(IRN);
expect(request.WithholdDetail).toMatchObject({ Type: "TWHT", PreTaxAmount: 6000, WithholdingAmount: 120 });
expect(receipt.status).toBe(EimsReceiptStatus.Registered);
expect(receipt.rrn).toBe("rrn-value");
});
it("requires an exchangeRate for a non-ETB invoice", async () => {
const db = new FakeDb([invoiceRow({ currency: "USD" })]);
const postBearer = jest.fn();
await expect(
build(db, postBearer).registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 6000,
withholdingAmount: 120,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(postBearer).not.toHaveBeenCalled();
});
});
describe("EimsReceiptService.listReceipts", () => {
it("returns every receipt filed against the invoice", async () => {
const db = new FakeDb([invoiceRow()]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const service = build(db, postBearer);
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "CASH" } as never);
await service.registerWithholdingReceipt(INVOICE_ID, {
type: "TWHT",
preTaxAmount: 100,
withholdingAmount: 2,
} as never);
const list = await service.listReceipts(INVOICE_ID);
expect(list).toHaveLength(2);
});
});

View File

@@ -0,0 +1,259 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } 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 { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import {
EimsReceiptResponse,
EimsSalesReceiptRequest,
EimsWithholdReceiptRequest,
} from "./eims-receipt.types";
import { EimsInvoiceError } from "./eims-registration.types";
/** `ReceiptCurrency` / withholding `InvoiceDetail.Currency` — confirmed by a live rule error. */
const EIMS_RECEIPT_CURRENCIES = ["ETB", "USD", "CAD"] as const;
/** Failure kinds where MoR gave a complete answer — the receipt definitively did not register. */
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
/**
* `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, both against an already-registered
* invoice.
*
* No counter/reservation machinery: the collection shows no MoR-enforced ordering on
* `ReceiptCounter` the way `InvoiceCounter` has a documented "expected: N" rule, so — unlike
* registration — there is no shared sequence to protect. Each attempt is its own `EimsReceipt` row:
* created before the HTTP call (so a crash mid-flight leaves an UNKNOWN row instead of nothing),
* settled after it. An ambiguous outcome is never auto-retried — receipts have no evidenced
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
* applies as an unacknowledged registration: a human must check the MoR portal first.
*
* Several request fields have no confirmed source in this codebase (payment method, collector,
* withholding rate/amount) and are never guessed — see the two DTOs.
*/
@Injectable()
export class EimsReceiptService {
private readonly logger = new Logger(EimsReceiptService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly notifications: NotificationsService,
) {}
private get cfg(): EimsConfig {
return this.config.get<EimsConfig>("eims")!;
}
async registerSalesReceipt(invoiceId: string, dto: RegisterSalesReceiptDto): Promise<EimsReceipt> {
const invoice = await this.loadRegisteredInvoice(invoiceId);
const currency = dto.currency ?? invoice.currency;
this.assertReceiptCurrency(currency);
const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice);
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
const balance = Number(invoice.balanceAmount);
const request: EimsSalesReceiptRequest = {
ReceiptNumber: receiptNumber,
ReceiptType: "Sales Receipts",
Reason: dto.reason ?? "Payment received",
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
// error for this field was ever observed to confirm which form MoR actually requires.
ReceiptDate: new Date().toISOString(),
ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber,
SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber,
ReceiptCurrency: currency,
ExchangeRate: dto.exchangeRate ?? null,
CollectedAmount: collectedAmount,
SellerTIN: this.cfg.tin,
Invoices: [
{
InvoiceIRN: invoice.eimsIrn!,
PaymentCoverage: dto.paymentCoverage ?? (balance <= 0 ? "FULL" : "PARTIAL"),
InvoicePaidAmount: collectedAmount,
DiscountAmount: null,
RemainingAmount: balance,
TotalAmount: Number(invoice.totalAmount),
},
],
TransactionDetails: {
ModeOfPayment: dto.modeOfPayment,
ChequeNumber: dto.chequeNumber ?? null,
CPONumber: dto.cpoNumber ?? null,
DocumentNumber: dto.documentNumber ?? null,
CollectorName: dto.collectorName ?? null,
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
AccountNumber: dto.accountNumber ?? null,
TransactionNumber: dto.transactionNumber ?? null,
},
};
return this.submit(invoice, "SALES", receiptNumber, request, "/v1/receipt/sales");
}
async registerWithholdingReceipt(
invoiceId: string,
dto: RegisterWithholdingReceiptDto,
): Promise<EimsReceipt> {
const invoice = await this.loadRegisteredInvoice(invoiceId);
if (invoice.currency !== "ETB" && dto.exchangeRate == null) {
throw new BadRequestException({
code: "EIMS_EXCHANGE_RATE_REQUIRED",
message: `Invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`,
});
}
const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice);
const request: EimsWithholdReceiptRequest = {
ReceiptNumber: receiptNumber,
Reason: dto.reason ?? "Withholding",
ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber,
SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber,
InvoiceDetail: {
InvoiceIRN: invoice.eimsIrn!,
Currency: invoice.currency,
ExchangeRate: dto.exchangeRate ?? null,
},
WithholdDetail: {
Type: dto.type,
Rate: dto.rate ?? null,
PreTaxAmount: dto.preTaxAmount,
WithholdingAmount: dto.withholdingAmount,
},
};
return this.submit(invoice, "WITHHOLDING", receiptNumber, request, "/v1/receipt/withholding");
}
async listReceipts(invoiceId: string): Promise<EimsReceipt[]> {
return this.dataSource.manager.find(EimsReceipt, {
where: { invoiceId },
order: { createdAt: "DESC" },
});
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
private async submit(
invoice: Invoice,
kind: EimsReceiptKind,
receiptNumber: string,
request: EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
path: "/v1/receipt/sales" | "/v1/receipt/withholding",
): Promise<EimsReceipt> {
// Committed before the HTTP call — a crash mid-flight leaves an UNKNOWN row, not nothing.
const receipt = await this.dataSource.manager.save(EimsReceipt, {
invoiceId: invoice.id,
kind,
status: EimsReceiptStatus.Submitting,
receiptNumber,
submittedAt: new Date(),
request: request as unknown as Record<string, unknown>,
});
try {
const response = await this.client.postBearer<
EimsSalesReceiptRequest | EimsWithholdReceiptRequest,
EimsReceiptResponse
>(path, request);
const rrn = response?.body?.rrn;
if (!rrn) {
throw new EimsApiException(
"SCHEMA_VALIDATION",
"EIMS receipt registration returned no rrn",
response?.statusCode,
);
}
await this.dataSource.manager.update(EimsReceipt, receipt.id, {
status: EimsReceiptStatus.Registered,
rrn,
qr: response.body?.qr ?? null,
ackStatus: response.body?.status ?? null,
});
this.logger.log(`${kind} receipt ${receiptNumber} registered for invoice ${invoice.invoiceNumber} (RRN ${rrn})`);
await this.notifyBuyer(invoice, kind, receiptNumber);
} catch (err) {
const api = err instanceof EimsApiException ? err : null;
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
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.manager.update(EimsReceipt, receipt.id, {
status: deterministic ? EimsReceiptStatus.Failed : EimsReceiptStatus.Unknown,
lastError,
} as QueryDeepPartialEntity<EimsReceipt>);
this.logger.error(
`${kind} receipt ${receiptNumber} for invoice ${invoice.invoiceNumber} ${deterministic ? "FAILED" : "UNKNOWN"}: ${lastError.message}`,
);
throw err;
}
const saved = await this.dataSource.manager.findOne(EimsReceipt, { where: { id: receipt.id } });
return saved!;
}
private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise<void> {
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
invoice.companyId,
`A ${kind === "SALES" ? "sales" : "withholding"} receipt (${receiptNumber}) has been registered ` +
`with MoR EIMS for invoice ${invoice.invoiceNumber}.`,
);
} catch (err) {
this.logger.warn(`EIMS receipt buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`);
}
}
private async loadRegisteredInvoice(invoiceId: string): Promise<Invoice> {
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (!invoice.eimsIrn) {
throw new BadRequestException({
code: "EIMS_NOT_REGISTERED",
message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — no IRN to file a receipt against.`,
});
}
return invoice;
}
private assertReceiptCurrency(currency: string): void {
if (!EIMS_RECEIPT_CURRENCIES.includes(currency as (typeof EIMS_RECEIPT_CURRENCIES)[number])) {
throw new BadRequestException({
code: "EIMS_RECEIPT_CURRENCY_INVALID",
message: `EIMS receipt currency must be one of ${EIMS_RECEIPT_CURRENCIES.join(", ")}, got "${currency}"`,
});
}
}
private generateReceiptNumber(invoice: Invoice): string {
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
}
}

View File

@@ -0,0 +1,97 @@
/**
* Wire types for `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, taken verbatim from
* the Postman collection's saved requests/responses. Bearer-authenticated but unsigned — no
* `{request,signature,certificate}` envelope in the collection's saved bodies, same as `/v1/verify`
* and `/v1/cancel`.
*/
/** One entry of `Invoices[]` on a sales receipt. */
export interface EimsSalesReceiptInvoice {
InvoiceIRN: string;
PaymentCoverage: string;
InvoicePaidAmount: number;
DiscountAmount: number | null;
RemainingAmount: number | null;
TotalAmount: number;
}
/**
* `ModeOfPayment` enum confirmed by a live schema error: exactly these eight values, verbatim
* spelling and casing (including the two multi-word ones).
*/
export const EIMS_MODE_OF_PAYMENT = [
"CASH",
"CHEQUE",
"CPO",
"Local Bank Transfer",
"SWIFT",
"Wire Transfer",
"Letter of Credit",
"Card",
] as const;
export type EimsModeOfPayment = (typeof EIMS_MODE_OF_PAYMENT)[number];
export interface EimsSalesReceiptTransactionDetails {
ModeOfPayment: EimsModeOfPayment;
ChequeNumber: string | null;
CPONumber: string | null;
DocumentNumber: string | null;
CollectorName: string | null;
PaymentServiceProvider: string | null;
OtherPaymentServiceProviderName: string | null;
AccountNumber: string | null;
TransactionNumber: string | null;
}
export interface EimsSalesReceiptRequest {
ReceiptNumber: string;
ReceiptType: string;
Reason: string;
ReceiptDate: string;
ReceiptCounter: string;
ManualReceiptNumber: string;
SourceSystemType: string;
SourceSystemNumber: string;
/** Confirmed enum via a live rule error: "Currency should be ETB, USD, CAD". */
ReceiptCurrency: string;
ExchangeRate: number | null;
CollectedAmount: number;
SellerTIN: string;
Invoices: EimsSalesReceiptInvoice[];
TransactionDetails: EimsSalesReceiptTransactionDetails;
}
export interface EimsWithholdReceiptRequest {
ReceiptNumber: string;
Reason: string;
ReceiptCounter: string;
ManualReceiptNumber: string;
SourceSystemType: string;
SourceSystemNumber: string;
InvoiceDetail: {
InvoiceIRN: string;
Currency: string;
ExchangeRate: number | null;
};
WithholdDetail: {
/** Only "TWHT" has ever been observed; not restricted to it, since no fuller enum is confirmed. */
Type: string;
Rate: number | null;
PreTaxAmount: number;
WithholdingAmount: number;
};
}
/** Success body — identical shape for both receipt endpoints. */
export interface EimsReceiptResponseBody {
status: string;
rrn: string;
/** Base64 PNG, same convention as the register response's `signedQR`. */
qr: string;
}
export interface EimsReceiptResponse {
statusCode?: number;
message?: string;
body?: EimsReceiptResponseBody;
}

View File

@@ -13,6 +13,8 @@ export enum EimsInvoiceStatus {
Registered = "REGISTERED",
Failed = "FAILED",
Unknown = "UNKNOWN",
/** Successfully cancelled at MoR via `POST /v1/cancel`. Terminal — never re-registered. */
Cancelled = "CANCELLED",
}
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
body?: EimsVerifyResponseBody;
}
/**
* `POST /v1/cancel` — bearer-authenticated but unsigned, same shape as `/v1/verify` (no
* `{request,signature,certificate}` envelope in the collection's saved request).
*/
export interface EimsCancelRequest {
Irn: string;
/** Numeric code as a string, e.g. "1" (Duplicate), "6" (Calculation Error) per the collection docs. */
ReasonCode: string;
Remark: string;
}
/** Success body: `{ cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" }` — a Java Date#toString(), not ISO. Stored verbatim, like `eimsAckDate`. */
export interface EimsCancelResponseBody {
cancellationDate: string;
}
export interface EimsCancelResponse {
statusCode?: number;
message?: string;
body?: EimsCancelResponseBody;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
/** Raw base64 PNG from MoR, already rendered on their side — see `Invoice.eimsSignedQr`. */
eimsSignedQr: string | null;
eimsCancelledAt: Date | null;
eimsCancellationDate: string | null;
eimsCancellationReasonCode: string | null;
eimsCancellationRemark: string | null;
}

View File

@@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types";
*
* 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),
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not
* assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names
* `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named
* `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default.
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
* 5. base64 of the certificate file's exact bytes.
* 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own
* worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain
* text-file format ours is, base64'd with no re-encoding.
*
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
* object is never mutated after serialization.

View File

@@ -35,8 +35,14 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
buyerCountryCode: null,
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},
discountByChargeType: {},
cashierName: null,
salesPersonName: null,
buyerIdType: null,
buyerIdNumber: null,
...over,
});

View File

@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsCancellationService } from "./eims-cancellation.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 { EimsReceiptService } from "./eims-receipt.service";
import { EimsSignerService } from "./eims-signer.service";
import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity";
/**
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
@Module({
imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
NotificationInboxModule,
NotificationsModule,
],
controllers: [EimsInvoiceController],
providers: [
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsClientService,
EimsInvoiceRegistrationService,
EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
],
exports: [
EimsAuthService,
EimsClientService,
EimsInvoiceRegistrationService,
EimsCancellationService,
EimsReceiptService,
],
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
})
export class EimsModule {}

View File

@@ -0,0 +1,68 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "../../billing/entities/invoice.entity";
import { EimsInvoiceError } from "../eims-registration.types";
export type EimsReceiptKind = "SALES" | "WITHHOLDING";
/** Same shape as `EimsInvoiceStatus`, for a receipt instead of an invoice. */
export enum EimsReceiptStatus {
NotSubmitted = "NOT_SUBMITTED",
Submitting = "SUBMITTING",
Registered = "REGISTERED",
Failed = "FAILED",
Unknown = "UNKNOWN",
}
/**
* One `POST /v1/receipt/sales` or `POST /v1/receipt/withholding` attempt, linked to the invoice it
* was filed against. An invoice can have more than one receipt (partial payments), so this is its
* own table rather than columns on `Invoice`.
*
* No counter/sequence reservation the way invoice registration has: the collection's `/v1/receipt/*`
* examples show no MoR-enforced ordering rule on `ReceiptCounter` (unlike `InvoiceCounter`'s
* documented "expected: N"), so this is a plain row per attempt.
*/
@Entity({ schema: "freight", name: "eims_receipts" })
@Index(["invoiceId"])
export class EimsReceipt extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid" })
invoiceId!: string;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoice_id" })
invoice?: Invoice;
@Column({ name: "kind", type: "varchar", length: 16 })
kind!: EimsReceiptKind;
@Column({ name: "status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
status!: EimsReceiptStatus;
/** Our own generated `ReceiptNumber` / `ManualReceiptNumber` (both sent equal — see the service). */
@Column({ name: "receipt_number", type: "varchar", length: 64 })
receiptNumber!: string;
/** MoR's Receipt Reference Number, returned only on success. */
@Column({ name: "rrn", type: "text", nullable: true })
rrn?: string | null;
/** Base64 PNG from MoR, same convention as `Invoice.eimsSignedQr` — embed directly. */
@Column({ name: "qr", type: "text", nullable: true })
qr?: string | null;
/** MoR's own `"A"` (active) / `"F"` (failed) status marker, stored verbatim. */
@Column({ name: "ack_status", type: "varchar", length: 8, nullable: true })
ackStatus?: string | null;
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
submittedAt?: Date | null;
@Column({ name: "last_error", type: "jsonb", nullable: true })
lastError?: EimsInvoiceError | null;
/** The exact request body sent — receipts are bearer-only/unsigned, so nothing secret is in it. */
@Column({ name: "request", type: "jsonb", nullable: true })
request?: Record<string, unknown> | null;
}

View File

@@ -30,8 +30,11 @@ export class EimsSystemState extends BaseEntity {
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
/**
* IRN of the last successful registration; null until the first one succeeds. `text`, not a
* fixed varchar — same reasoning as `Invoice.eimsIrn`: a real IRN already overflowed varchar(64).
*/
@Column({ name: "previous_irn", type: "text", nullable: true })
previousIrn?: string | null;
/**

View File

@@ -0,0 +1,29 @@
import { NotificationsGateway } from "./notifications.gateway";
import { WsAuthService } from "./ws-auth.service";
const gateway = () => new NotificationsGateway({} as WsAuthService);
describe("NotificationsGateway", () => {
it("skips emitNew rather than throwing when no WebSocket server is attached", () => {
const g = gateway();
expect(() => g.emitNew("user-1", { id: "n-1" } as never, 3)).not.toThrow();
});
it("skips emitUnreadCount rather than throwing when no WebSocket server is attached", () => {
const g = gateway();
expect(() => g.emitUnreadCount("user-1", 3)).not.toThrow();
});
it("pushes to the user's room once a server is attached", () => {
const g = gateway();
const emit = jest.fn();
const to = jest.fn().mockReturnValue({ emit });
(g as unknown as { server: { to: typeof to } }).server = { to };
g.emitNew("user-1", { id: "n-1" } as never, 3);
expect(to).toHaveBeenCalledWith("user:user-1");
expect(emit).toHaveBeenCalledWith("notification:new", { id: "n-1" });
expect(emit).toHaveBeenCalledWith("notification:unread-count", 3);
});
});

View File

@@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service";
export class NotificationsGateway implements OnGatewayConnection {
private readonly logger = new Logger(NotificationsGateway.name);
// Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP
// listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`.
@WebSocketServer()
private readonly server!: Server;
private readonly server?: Server;
constructor(private readonly wsAuth: WsAuthService) {}
@@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection {
/** Push a freshly-created notification + the new unread count to a user. */
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
if (!this.server) return this.skip("emitNew");
const room = this.server.to(this.room(userId));
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
@@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection {
/** Push only an updated unread count (e.g. after a read on another tab). */
emitUnreadCount(userId: string, unreadCount: number): void {
if (!this.server) return this.skip("emitUnreadCount");
this.server
.to(this.room(userId))
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
}
/**
* `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP
* listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not
* for the brief window before `app.listen()` completes in a real boot either. The notification row
* is already persisted by this point (the caller writes it before pushing), so a missing socket
* server just means "no live push this time" — skip it rather than throw and lose the caller's
* own result (e.g. an EIMS registration outcome that already succeeded or failed for real).
*/
private skip(method: string): void {
this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`);
}
private room(userId: string): string {
return `user:${userId}`;
}

View File

@@ -1,6 +1,7 @@
// otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository";
@@ -317,6 +318,13 @@ export class OtpService {
}`;
if (result === "ok") this.logger.log(line);
else this.logger.warn(line);
// Outcome only — the target is a phone number / email address and stays out
// of the canonical line. Channels are safe and say which one was used.
logCtx(
{ mode, result, channels: channelsOf(target) },
{ path: "otp.verify", mode: "push" },
);
}
/**

View File

@@ -7,6 +7,7 @@ import {
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import { logCtx } from "@edr/api-common";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
@@ -111,6 +112,16 @@ export class PaymentClientService {
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
// Every hop to the payment service lands on the request log line: which
// call, how slow, and what it answered. A settle that never happened is
// almost always one of these coming back 4xx/5xx or timing out.
const startedAt = Date.now();
const trace = (extra: Record<string, unknown>) =>
logCtx(
{ method, path, ms: Date.now() - startedAt, ...extra },
{ path: "outbound.payment", mode: "push" },
);
try {
const response = await firstValueFrom(
this.http.request<T>({
@@ -122,9 +133,11 @@ export class PaymentClientService {
: {},
}),
);
trace({ status: response.status });
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
trace({ status: err.response.status });
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
@@ -136,6 +149,7 @@ export class PaymentClientService {
}
const message =
err instanceof Error && err.message ? err.message : String(err);
trace({ unreachable: true, error: message });
this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`,
);

View File

@@ -7,6 +7,7 @@ import {
Logger,
NotFoundException,
} from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
@@ -214,8 +215,16 @@ export class PaymentService {
PaymentReferenceType.SHIPMENT,
referenceId,
);
logCtx(
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
{ path: "payment.reconcile" },
);
return { paid: result.paid, unverifiable: result.unverifiable };
} catch (err) {
logCtx(
{ referenceId, unverifiable: true, error: (err as Error).message },
{ path: "payment.reconcile" },
);
this.logger.warn(
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
);
@@ -224,6 +233,18 @@ export class PaymentService {
}
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
logCtx(
{
referenceId: input.referenceId,
source: input.source,
orderRef: input.orderRef,
method: input.method,
amountMinor: input.amountMinor,
currency: input.currency,
platform: input.platform,
},
{ path: "payment.initiate" },
);
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
@@ -268,6 +289,17 @@ export class PaymentService {
const intent = await this.upsertIntent(input, snapshot);
logCtx(
{
intentId: intent.id,
providerStatus: snapshot.status,
providerTxnId: snapshot.providerTxnId,
merchantOrderId: snapshot.merchantOrderId,
immediateSuccess,
},
{ path: "payment.initiate" },
);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
@@ -428,6 +460,17 @@ export class PaymentService {
otp,
);
logCtx(
{
intentId: local.id,
refId: local.refId,
gatewayIntentId: snapshot.intentId,
providerStatus: confirmed.status,
failureCode: confirmed.failureCode,
},
{ path: "payment.confirmOtp" },
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
@@ -460,6 +503,16 @@ export class PaymentService {
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
logCtx(
{
intentId,
refId: intent.refId,
priorStatus: intent.status,
providerTxnId: opts.providerTxnId,
notifyBilling: opts.notify !== false,
},
{ path: "payment.settle" },
);
if (intent.status === "success") {
// Still notify billing: a prior delivery may have flipped the intent to
// success and then died before the invoice settled (the two steps are not
@@ -471,6 +524,7 @@ export class PaymentService {
opts.paidAt ?? intent.paidAt ?? undefined,
);
}
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
return { alreadyFinalized: true };
}
@@ -507,6 +561,15 @@ export class PaymentService {
referenceId: string,
): Promise<{ acknowledged: boolean }> {
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
logCtx(
{
referenceId,
intentId: intent?.id,
intentStatus: intent?.status ?? "none",
method: intent?.method,
},
{ path: "payment.successRedirect" },
);
if (!intent || intent.method === "cbe-bill") {
return { acknowledged: false };
}
@@ -533,6 +596,16 @@ export class PaymentService {
}): Promise<void> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
logCtx(
{
intentId: intent.id,
refId: intent.refId,
priorStatus: intent.status,
failureCode: input.failureCode,
failureMessage: input.failureMessage,
},
{ path: "payment.failed" },
);
if (intent.status === "success" || intent.status === "canceled") return;
await this.paymentRepo.update(

View File

@@ -70,6 +70,15 @@ export class CreateCargoTypeDto {
@IsBoolean()
hasLashing?: boolean;
@ApiPropertyOptional({
default: false,
description:
'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.',
})
@IsOptional()
@IsBoolean()
hasFuel?: boolean;
@ApiPropertyOptional({
default: false,
description:

View File

@@ -7,7 +7,8 @@ import {
RATE_UNITS,
} from '../entities/rate.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
const CURRENCIES = ['USD', 'ETB'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
@@ -94,6 +95,17 @@ export class CreateRateDto {
@IsIn([...RATE_UNITS])
rateUnit?: string;
@ApiPropertyOptional({
description:
'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
baseLiters?: number;
@ApiPropertyOptional({
description:
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',

View File

@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'has_lashing', type: 'boolean', default: false })
hasLashing!: boolean;
/**
* Whether bookings of this cargo incur the fuel surcharge. Billed off the
* lane-scoped FUEL rate for the booking's direction + route + this cargo
* type (per liter or per wagon).
*/
@Column({ name: 'has_fuel', type: 'boolean', default: false })
hasFuel!: boolean;
/**
* Whether staff may write bulk contract templates against this cargo type.
* Mutually exclusive between a parent group and its children: if the parent

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}
}

View File

@@ -74,6 +74,9 @@ function unitsForShape(input: {
case 'LASHING':
// Bulk-only cargo securing — per ton or per wagon.
return ['PER_TON', 'PER_WAGON'];
case 'FUEL':
// Per wagon (wagons × rate) or per liter (baseLiters × rate, once).
return ['PER_WAGON', 'PER_LITER'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':

View File

@@ -24,6 +24,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const;
export type RateType = typeof RATE_TYPES[number];
@@ -41,6 +42,8 @@ export const RATE_UNITS = [
'PER_KM',
// Last-mile bulk: price = tons × km × rateValue.
'PER_TON_KM',
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
'PER_LITER',
'PER_INVOICE',
'FLAT',
] as const;
@@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type).
'FUEL',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
@@ -163,6 +169,14 @@ export class Rate extends BaseEntity {
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
* open-ended). NULL on every other rate shape.
*/
/**
* FUEL rates billed PER_LITER only: the liters the surcharge covers —
* price = baseLiters × rateValue, once per booking. NULL on every other
* rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead).
*/
@Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true })
baseLiters?: number | null;
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
minKm?: number | null;

View File

@@ -422,3 +422,108 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)',
expect(lashingMods(result)).toHaveLength(0);
});
});
describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
const fuelPerLiter: Rate = {
id: 'rate-fuel-liter',
rateType: 'FUEL_SURCHARGE',
trigger: 'FUEL',
rateValue: 2,
rateUnit: 'PER_LITER',
baseLiters: 100,
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: 'cargo-steel',
tradeDirection: 'IMPORT',
originYardId: 'yard-nagad',
destinationYardId: 'yard-mojo',
} as Rate;
const buildService = (rates: Rate[], hasFuel = true): RuleEngineService =>
new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const fuelInput = (
overrides: Partial<BookingEvaluationInput> = {},
): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
cargoTypeId: 'cargo-steel',
originYardId: 'yard-nagad',
destinationYardId: 'yard-mojo',
totalWagons: 0,
bulkTons: 100,
bulkWagons: 4,
containers: [],
...overrides,
});
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => {
const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
const mods = fuelMods(result);
expect(mods).toHaveLength(1);
// Flat: the customer sees only the total, and a frozen contract snapshot
// (also stored flat) multiplies it by quantity 1 — never by the liters.
expect(mods[0].triggerValue).toBe(1);
expect(mods[0].unitPriceUsd).toBe(200);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('FLAT');
});
it('PER_WAGON bills the wagons the cargo occupies', async () => {
const result = await buildService([
{ ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate,
]).evaluate(fuelInput());
const mods = fuelMods(result);
expect(mods[0].triggerValue).toBe(4);
expect(mods[0].calculatedAmount).toBe(200);
});
it('a rate for another lane, direction or commodity never bills', async () => {
for (const wrong of [
{ tradeDirection: 'EXPORT' },
{ originYardId: 'yard-other' },
{ destinationYardId: 'yard-other' },
{ cargoTypeId: 'cargo-wheat' },
]) {
const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate(
fuelInput(),
);
expect(fuelMods(result)).toHaveLength(0);
}
});
it('a domestic booking bills the DOMESTIC fuel lane', async () => {
const result = await buildService([
{ ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate,
]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' }));
expect(fuelMods(result)).toHaveLength(1);
});
it('no fuel charge when the cargo type does not have hasFuel', async () => {
const result = await buildService([fuelPerLiter], false).evaluate(fuelInput());
expect(fuelMods(result)).toHaveLength(0);
});
it('no matching lane rate bills nothing (lenient, like lashing)', async () => {
const result = await buildService([]).evaluate(fuelInput());
expect(fuelMods(result)).toHaveLength(0);
});
});

View File

@@ -175,6 +175,9 @@ export class RuleEngineService {
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
// input flag when no cargo type is set (e.g. container bookings).
let hasLashing = input.hasLashing === true;
// Fuel is likewise a cargo-type property (hasFuel), billed off the
// lane-scoped FUEL rate — see fuelCharges.
let hasFuel = false;
if (input.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
@@ -186,6 +189,9 @@ export class RuleEngineService {
if (cargoType.hasLashing) {
hasLashing = true;
}
if (cargoType.hasFuel) {
hasFuel = true;
}
}
}
@@ -328,6 +334,9 @@ export class RuleEngineService {
// Lashing is sold per cargo kind + container type — billed by the
// kind-aware block below, never by this generic loop.
if (rate.trigger === 'LASHING') continue;
// Fuel is sold per lane + cargo type — billed by the route-matched
// block below, never by this route-agnostic loop.
if (rate.trigger === 'FUEL') continue;
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
@@ -442,6 +451,10 @@ export class RuleEngineService {
appliedModifiers.push(...this.lashingCharges(input, liveRates));
}
if (hasFuel) {
appliedModifiers.push(...this.fuelCharges(input, liveRates));
}
return {
priorityScore,
appliedModifiers,
@@ -632,6 +645,56 @@ export class RuleEngineService {
return modifiers;
}
/**
* Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
* billed off the FUEL rate matching the booking's lane (trade direction +
* origin + destination) and cargo type. PER_LITER collapses to one FLAT
* amount (baseLiters × rateValue, once per booking) — the customer only ever
* sees the total, and the frozen contract snapshot stores that same flat
* figure so the snapshot-override math bills it exactly once. PER_WAGON
* bills the wagons the cargo occupies. No matching lane rate simply bills
* nothing — same leniency as lashing.
*/
private fuelCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
const rate = liveRates.find(
(r) =>
r.trigger === 'FUEL' &&
r.currency === 'USD' &&
r.tradeDirection === input.tradeDirection &&
r.originYardId === input.originYardId &&
r.destinationYardId === input.destinationYardId &&
r.cargoTypeId === input.cargoTypeId,
);
if (!rate) return modifiers;
const rateValue = Number(rate.rateValue);
const wagons = Math.max(
0,
Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0),
);
const perLiter = rate.rateUnit === 'PER_LITER';
const billedQty = perLiter ? 1 : wagons;
const unitPrice = perLiter
? Number(rate.baseLiters ?? 0) * rateValue
: rateValue;
const amount = billedQty * unitPrice;
if (!(amount > 0)) return modifiers;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: unitPrice,
billingUnit: perLiter ? 'FLAT' : rate.rateUnit,
});
return modifiers;
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => {
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => {
const { service } = build({
rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }),
});
const request = await service.submit({
rateId: 'rate-1',
update: { rateUnit: 'PER_LITER', baseLiters: 3 },
});
expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(

View File

@@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [
// LIVE last-mile rate would diff to "nothing changed".
'minKm',
'maxKm',
// PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER
// dropped the submitted liters and validation failed with "needs a base
// liters amount" even though the payload carried one.
'baseLiters',
] as const;
/**

View File

@@ -121,15 +121,16 @@ export class RatesService {
}
/**
* Rates sold per direction + route. Base freight always; customs clearance
* and empty-container return are the surcharges that are too — their fee
* depends on the lane (and, for returns, the container type).
* Rates sold per direction + route. Base freight always; customs clearance,
* empty-container return and fuel are the surcharges that are too — their
* fee depends on the lane (and, for returns, the container type).
*/
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN'
trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
);
}
@@ -160,7 +161,9 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
tradeDirection: string | null,
): { origin: YardCountry; destination: YardCountry } {
if (appliesTo === 'INTERCITY') {
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
// inside Ethiopia exactly like intercity base freight.
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
}
return tradeDirection === 'EXPORT'
@@ -291,6 +294,31 @@ export class RatesService {
}
return;
}
if (trigger === 'FUEL') {
// Fuel is sold per lane + commodity: the direction says which countries
// the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo
// type names the commodity — different commodities price differently.
if (
tradeDirection !== 'IMPORT' &&
tradeDirection !== 'EXPORT' &&
tradeDirection !== 'DOMESTIC'
) {
throw new BadRequestException(
'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
);
}
if (containerTypeId) {
throw new BadRequestException(
'A fuel rate cannot be scoped to a container type.',
);
}
if (!cargoTypeId) {
throw new BadRequestException(
'A fuel rate must name the cargo type it covers.',
);
}
return;
}
if (trigger === 'WITH_RETURN') {
// Returning the empty box only exists on imports (the box goes back to
// the port) — export return rates are rejected until the business sells
@@ -502,15 +530,21 @@ export class RatesService {
: (dto.containerTypeId ?? null);
const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING'
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.cargoTypeId ?? null)
: isSurcharge
? null
: (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store —
// its yard pair already says where it runs.
// its yard pair already says where it runs. (Fuel is the exception: its
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
// nothing about the direction.)
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
@@ -563,6 +597,8 @@ export class RatesService {
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
}
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
@@ -588,6 +624,7 @@ export class RatesService {
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
rateValue: dto.rateValue,
rateUnit,
baseLiters,
minKm,
maxKm,
status: 'DRAFT',
@@ -595,6 +632,25 @@ export class RatesService {
});
}
/**
* The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue,
* once per booking). Required there; cleared on every other rate shape —
* a PER_WAGON fuel rate bills wagons × rateValue and carries none.
*/
private resolveBaseLiters(
rateUnit: Rate['rateUnit'],
baseLiters?: number | null,
): number | null {
if (rateUnit !== 'PER_LITER') return null;
const liters = Number(baseLiters);
if (!(liters > 0)) {
throw new BadRequestException(
'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.',
);
}
return liters;
}
/**
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
@@ -680,14 +736,18 @@ export class RatesService {
const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING';
trigger === 'LASHING' ||
trigger === 'FUEL';
const cargoTypeId = !keepsCargoType
? null
: dto.cargoTypeId !== undefined
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection
@@ -788,6 +848,11 @@ export class RatesService {
ignoreId: id,
});
updates.baseLiters = this.resolveBaseLiters(
rateUnit,
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
);
updates.currency =
appliesTo === 'LAST_MILE'
? (dto.currency ?? existing.currency ?? 'ETB')

View File

@@ -15,21 +15,21 @@ export class StampSettingsController {
constructor(private readonly service: StampSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin])
@BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
get() {
return this.service.getView();
}
@Put()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company stamp" })
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company stamp (invoices fall back to the plain seal)",
})

View File

@@ -129,6 +129,7 @@ import {
perEdgeConsistUsage,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
@@ -330,6 +331,8 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
interface BookingWindowRow {
schedule_id: string;
reference: string | null;
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
train_number: string | null;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
@@ -2928,6 +2931,10 @@ export class TrainSchedulingService {
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
// is confirmed — allocation time already enforces it, but a wagon swap or
// an edited allocation since then could have broken it unnoticed.
await this.assertWagonContainerCapacity(scheduleId);
// Export cargo must be received at the warehouse with a GRN before it can
// be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) {
@@ -3395,6 +3402,72 @@ export class TrainSchedulingService {
}
}
/**
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
* time. `validateContainerPlacements` already enforces this the moment a
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
* wagon swap, an edited allocation, or a container item added out-of-band
* between allocation and loading could still leave a wagon over its 2-TEU
* capacity undetected until the train is already loaded. This closes that gap
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
* same size resolution as the marshalling document) right before loading is
* confirmed.
*/
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
pw.wagon_number AS "wagonNumber",
SUM(
CASE COALESCE(ct.size_ft, bct.size_ft)
WHEN 40 THEN 2
WHEN 20 THEN 1
ELSE
CASE
WHEN bc.container_size ILIKE '%40%' THEN 2
WHEN bc.container_size ILIKE '%20%' THEN 1
ELSE 0
END
END
) AS "teuUsed"
FROM freight.train_set_wagons tsw
JOIN freight.wagon_booking_allocations wba
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
JOIN freight.wagon_allocation_container_items wci
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
WHERE tsw.train_set_id = (
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
)
AND tsw.deleted_at IS NULL
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
HAVING SUM(
CASE COALESCE(ct.size_ft, bct.size_ft)
WHEN 40 THEN 2
WHEN 20 THEN 1
ELSE
CASE
WHEN bc.container_size ILIKE '%40%' THEN 2
WHEN bc.container_size ILIKE '%20%' THEN 1
ELSE 0
END
END
) > $2`,
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
);
if (rows.length) {
const labels = rows
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
.join(', ');
throw new BadRequestException(
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
);
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5872,6 +5945,7 @@ export class TrainSchedulingService {
wagonSlots: schedule.trainSet?.wagons,
storedWagonCount: schedule.trainSet?.wagonCount,
scheduleBookings: schedule.scheduleBookings,
maxWagons: schedule.maxWagons,
});
return {
@@ -6847,6 +6921,7 @@ export class TrainSchedulingService {
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -6903,6 +6978,7 @@ export class TrainSchedulingService {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -6948,9 +7024,7 @@ export class TrainSchedulingService {
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
train_number: string | null;
}
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.reference AS reference,
@@ -6980,14 +7054,13 @@ export class TrainSchedulingService {
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({
return rows.map((r) =>
this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
trainNumber: r.train_number,
}));
);
}
private mapBookingWindowRow(r: BookingWindowRow) {
@@ -7005,6 +7078,7 @@ export class TrainSchedulingService {
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
trainNumber: r.train_number ?? null,
contractId: r.contract_id,
contractKind: r.contract_kind,
direction: r.direction,
@@ -9301,9 +9375,14 @@ export class TrainSchedulingService {
}
// Every schedule the target train is committed to, via its train sets.
// Locomotives come along: the merged train is pulled by the union of this
// schedule's locos and the target train's, so capacity checks need both.
const targetSets = await this.dataSource
.getRepository(TrainSet)
.find({ where: { trainId: targetTrainId } });
.find({
where: { trainId: targetTrainId },
relations: { locomotives: { locomotive: true }, locomotive: true },
});
const targetSetIds = targetSets.map((s) => s.id);
const targetSchedules = targetSetIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
@@ -9346,6 +9425,24 @@ export class TrainSchedulingService {
.getRepository(Wagon)
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
// EVERY wagon physically on the source train moves with the merge — not
// just the ones coupled into this schedule's set. A wagon left behind
// would strand on the deactivated train. "Loose" = on the train but not
// backing a set slot; it joins the counts and the capacity math.
const sourceWagons = sourceTrainId
? await this.dataSource
.getRepository(Wagon)
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
: [];
const coupledPhysicalIds = new Set(
(schedule.trainSet?.wagons ?? [])
.map((w) => w.physicalWagonId)
.filter(Boolean),
);
const looseSourceWagons = sourceWagons.filter(
(w) => !coupledPhysicalIds.has(w.id),
);
const movingBookings = absorbed
? await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: absorbed.id },
@@ -9357,10 +9454,12 @@ export class TrainSchedulingService {
schedule,
sourceTrainId,
targetTrain,
targetSets,
absorbed,
affectedOthers,
untouched,
incomingWagons,
looseSourceWagons,
movingBookings,
};
}
@@ -9382,14 +9481,20 @@ export class TrainSchedulingService {
);
}
// ── Capacity: the merged consist must fit this schedule's locomotives ────
// ── Capacity: the merged consist must fit the merged train's locomotives ─
// Existing side = coupled set slots PLUS loose wagons riding the source
// train without a slot — they all move, so they all count.
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters) || 0,
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
cargoTons: 0,
}));
const wagonTypeIds = [
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
...new Set(
[...incomingWagons, ...plan.looseSourceWagons]
.map((w) => w.wagonTypeId)
.filter(Boolean),
),
];
const wagonTypes = wagonTypeIds.length
? await this.dataSource
@@ -9397,16 +9502,27 @@ export class TrainSchedulingService {
.find({ where: { id: In(wagonTypeIds) } })
: [];
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
const incomingSlots = incomingWagons.map((w) => {
const slotFromWagon = (w: Wagon) => {
const t = typeById.get(w.wagonTypeId);
return {
lengthMeters: Number(t?.lengthMeters) || 0,
tareWeightTons: Number(t?.tareWeightTons) || 0,
cargoTons: 0,
};
});
};
const incomingSlots = incomingWagons.map(slotFromWagon);
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
const limits = trainSetLocomotiveLimits(schedule.trainSet);
// The merged train is pulled by the union of this schedule's locomotives
// and whatever already pulls the target train (its sets keep their locos).
// Pull weight adds up across the pool; length stays the tightest cap.
const locoPool = [
...this.locomotivesOfTrainSet(schedule.trainSet),
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
];
const limits = combinedLocomotiveLimits([
...new Map(locoPool.map((l) => [l.id, l])).values(),
]);
if (limits) {
const rules = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
@@ -9415,13 +9531,17 @@ export class TrainSchedulingService {
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
});
const merged = [...existingSlots, ...incomingSlots];
// maxWagons is the schedule's own slot ceiling; fall back to the consist
// size when it is unset so the count axis never blocks spuriously.
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
// Merge is a physical consist move, so only the physical axes gate it:
// can this schedule's locomotives pull the merged weight and length.
// `schedule.maxWagons` is the booking-window planning ceiling — using it
// as a slot cap here blocked every merge into a bigger train (e.g. a
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
// ceiling to the merged size instead.
const violations = consistViolations(merged, {
maxWeightTons: caps.maxWeightTons,
maxLengthMeters: caps.maxLengthMeters,
maxWagonSlots: schedule.maxWagons || merged.length,
maxWagonSlots: merged.length,
});
blockers.push(...violations);
}
@@ -9481,7 +9601,10 @@ export class TrainSchedulingService {
const plan = await this.planMerge(scheduleId, targetTrainId);
const blockers = await this.mergeBlockers(plan);
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
// Coupled slots plus loose wagons on the source train — everything moves.
const existingCount =
(plan.schedule.trainSet?.wagons?.length ?? 0) +
plan.looseSourceWagons.length;
return {
canMerge: blockers.length === 0,
blockers,
@@ -9555,13 +9678,20 @@ export class TrainSchedulingService {
trainId: targetTrain.id,
});
// 2. The physical wagons follow the train.
// 2. The physical wagons follow the train — the target's stay put, and
// EVERY wagon on the source train (coupled or loose) moves across so
// nothing strands on the deactivated train.
if (incomingWagons.length) {
await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) },
{ trainId: targetTrain.id },
);
}
if (sourceTrainId) {
await manager
.getRepository(Wagon)
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
}
// 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder
@@ -9611,6 +9741,14 @@ export class TrainSchedulingService {
await manager
.getRepository(TrainSet)
.update(trainSetId, { wagonCount: mergedCount });
// 8. Booking capacity follows the consist: raise (never lower) the
// planning ceiling so the merged wagons are actually sellable.
if (mergedCount > (schedule.maxWagons ?? 0)) {
await manager
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: mergedCount });
}
});
this.logger.log(

View File

@@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => {
expect(usage.wagonsRemaining).toBe(0);
});
it('sells against the planned ceiling, not the partially coupled consist', () => {
// S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking
// that reserved 2 — the list read "2/2 used, 0 bookable" while the detail
// page and the booking gate (remainingWagonsForLeg vs maxWagons) both said
// 1 wagon was still free. Wagons couple on demand; the ceiling is capacity.
const usage = computeScheduleWagonUsage({
wagonSlots: Array(2).fill(slot(true)),
storedWagonCount: 2,
scheduleBookings: [booking(2)],
maxWagons: 3,
});
expect(usage.wagonsUsed).toBe(2);
expect(usage.wagonsTotal).toBe(3);
expect(usage.wagonsRemaining).toBe(1);
});
it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: [],

View File

@@ -20,11 +20,11 @@ export interface ScheduleBookingLike {
export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */
/** Schedule capacity — the larger of coupled consist and planned ceiling. */
wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */
/** Capacity minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number;
}
@@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: {
/** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null;
/** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */
maxWagons?: number | null;
}): ScheduleWagonUsage {
const slots = input.wagonSlots ?? [];
@@ -42,7 +44,14 @@ export function computeScheduleWagonUsage(input: {
// Prefer live slot rows; the stored counter drifts when a consist is edited
// without a recompute, which is why the list and detail disagreed on totals.
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
const coupled = slots.length || (input.storedWagonCount ?? 0);
// Wagons are coupled on demand as bookings are allocated, so a partially
// built consist does not cap what is bookable — the planned ceiling does
// (remainingWagonsForLeg sells against maxWagons). Without this, a schedule
// planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable"
// while its detail page and the booking gate both said 1 wagon was free.
const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0);
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce(

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
@@ -165,10 +166,13 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
* actually produce is a backoffice decision, edited in the file-settings editor.
*/
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
// First on purpose: only the first field of a new set is seeded, and this is
// the paper that distinguishes a co-operative from every other company.
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
displayOrder: 1,
},
{
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder {
async run() {
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
// Seed only into an empty table: any existing rows (including
// soft-deleted ones, which would still conflict on the unique `code`)
// mean the data is admin-managed, so leave it untouched.
const existing = await settingRepository.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`file_upload_settings already has ${existing} rows — skipping seed`,
);
return;
}
// Seed per CODE, not "only into an empty table". An existing row is
// admin-managed and never touched — including a soft-deleted one, which
// means the set was removed on purpose (and would still conflict on the
// unique `code`). What the table-wide check got wrong is the other half: a
// set added to this file after the first boot could never reach a database
// that already held the others, so it existed in code and nowhere else.
const existingCodes = new Set(
(await settingRepository.find({ withDeleted: true })).map((s) => s.code),
);
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
@@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder {
})),
];
// Insert setting rows only — no FileUploadField rows. Fields start empty
// and are configured from the backoffice file-settings editor; the field
// definitions above are kept as reference defaults.
await settingRepository.insert(
allSettings.map((documentSetting) => ({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
})),
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
if (missing.length === 0) {
this.logger.log("file upload settings up to date — nothing to seed");
return;
}
const inserted = await settingRepository.save(
missing.map((documentSetting) =>
settingRepository.create({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
}),
),
);
// A brand-new set gets exactly ONE field: its first reference default. The
// rest of the list above stays documentation — what a set actually asks for
// is a backoffice decision, edited in the file-settings editor. Seeding one
// means a set is never born empty (an empty set silently requires nothing),
// while leaving the admin a single row to extend rather than a list to prune.
const fieldRepository = this.dataSource.getRepository(FileUploadField);
const firstFields = inserted.flatMap((setting) => {
const reference = missing.find((s) => s.code === setting.code)?.fields[0];
return reference
? [fieldRepository.create({ ...reference, settingId: setting.id })]
: [];
});
if (firstFields.length > 0) await fieldRepository.save(firstFields);
this.logger.log(
`Seeded ${allSettings.length} file upload settings with empty fields`,
`Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing
.map((s) => s.code)
.join(", ")}`,
);
}
}

View File

@@ -482,6 +482,20 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:invoices:eims_resolve",
"Resolve a blocked MoR EIMS submission",
),
// Cancellation is a separate irreversible-at-MoR action from registration — its own grant,
// same reasoning as eims_register.
perm(
"d2b00001-0001-4000-8000-000000000008",
"edr_freight_app:invoices:eims_cancel",
"Cancel a registered invoice with MoR EIMS",
),
// Covers both sales and withholding receipts — same risk profile (filing a document with
// MoR), no reason to split further.
perm(
"d2b00001-0001-4000-8000-000000000009",
"edr_freight_app:invoices:eims_receipt_register",
"Register a sales or withholding receipt with MoR EIMS",
),
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
// the invoice. Moves money state, so it is its own grant, not part of view.
perm(
@@ -1190,22 +1204,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"b4b00002-0001-4000-8000-000000000001",
"edr_freight_app:settings:stamp:view",
"View stamp settings",
"View the company stamp",
),
perm(
"b4b00002-0001-4000-8000-000000000002",
"edr_freight_app:settings:stamp:manage",
"Manage stamp settings",
"Manage the company stamp",
),
// The per-officer approval teeter (ማህተም) — an individual's own stamp +
// signature, not the company seal. It used to ride on settings:stamp:*, which
// now gates the ONE company stamp; this key was split out when the two were
// untangled. `settings:invoice_stamp:*` retired at the same time: it gated the
// company stamp before the fold and is deliberately left orphaned in any DB
// that already seeded it (the seeder upserts by key and never deletes).
perm(
"b4b00003-0001-4000-8000-000000000001",
"edr_freight_app:settings:invoice_stamp:view",
"View invoice stamp settings",
"edr_freight_app:settings:teeter:view",
"View own approval teeter and signature",
),
perm(
"b4b00003-0001-4000-8000-000000000002",
"edr_freight_app:settings:invoice_stamp:manage",
"Manage invoice stamp settings",
"edr_freight_app:settings:teeter:manage",
"Manage own approval teeter and signature",
),
perm(
"b4c00001-0001-4000-8000-000000000001",
@@ -1701,6 +1721,8 @@ export const FREIGHT_PERMS = {
export: "edr_freight_app:invoices:export",
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
eimsCancel: "edr_freight_app:invoices:eims_cancel",
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
confirmOffline: "edr_freight_app:invoices:confirm_offline",
},
firstMile: {
@@ -1907,15 +1929,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
// The ONE company stamp/seal, applied to every generated document
// (invoices, receipts, warehouse papers, the EDR side of contracts).
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
// gate the company stamp before the two were untangled.
teeter: {
view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
@@ -2216,10 +2241,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.
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
// eims_receipt_register. 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,
],

View File

@@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -786,23 +785,27 @@ const App = () => {
</RequirePermission>
}
/>
{/*
The ONE company stamp, for every generated document. The per-officer
teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings
now lives at /user-management/teeter-and-signature — it is a different
thing (an individual's approval stamp), and pairing the two here was
the duplication.
*/}
<Route
path="stamp-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
<StampSettings />
<RequirePermission
permission={FREIGHT_PERMS.settings.stamp.view}
>
<CompanyStampSettingsPage />
</RequirePermission>
}
/>
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
<Route
path="invoice-stamp-settings"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
>
<InvoiceStampSettingsPage />
</RequirePermission>
}
element={<Navigate to="/dashboard/stamp-settings" replace />}
/>
<Route
path="contract-templates"

View File

@@ -0,0 +1,112 @@
import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
export interface PersonField {
label: string;
value?: string | null;
}
export interface PersonCardProps {
/** OWNER / POA / CONTACT PERSON — the person's role, not their name. */
title: string;
icon?: ReactNode;
/**
* Identity state. `undefined` = this person has no identity check at all
* (contact person), so no badge is rendered rather than a misleading "not
* verified" one.
*/
verified?: boolean;
/** Extra pills after the verification badge (e.g. "Verifies for this company"). */
badges?: ReactNode;
/** Rendered between the header and the fields — alerts, match warnings. */
notice?: ReactNode;
fields: PersonField[];
/** Shown when the API returned nothing for every field. */
emptyMessage: string;
/** Attachments or anything else that belongs to this person. */
children?: ReactNode;
}
/**
* One person in the customer's people column: owner, power of attorney, contact
* person. Empty fields are dropped rather than rendered as "—", so a field the
* API stops sending simply disappears instead of leaving a dead row behind.
*/
export function PersonCard({
title,
icon,
verified,
badges,
notice,
fields,
emptyMessage,
children,
}: PersonCardProps) {
const filled = fields.filter(
(f) => f.value != null && String(f.value).trim(),
);
return (
<Card>
<Stack gap="sm">
<Group gap={8} wrap="wrap">
{icon}
<Text
size="xs"
fw={700}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
{title}
</Text>
{verified !== undefined &&
(verified ? (
<Badge size="xs" color="edr-green" variant="light">
Fayda verified
</Badge>
) : (
<Badge size="xs" color="gray" variant="light">
Not verified
</Badge>
))}
{badges}
</Group>
{notice}
{filled.length > 0 ? (
<Stack gap="xs">
{filled.map((f) => (
<Stack key={f.label} gap={0}>
<Text size="xs" c="edr-muted">
{f.label}
</Text>
<Text
size="sm"
c="edr-text"
style={{ wordBreak: "break-word" }}
>
{f.value}
</Text>
</Stack>
))}
</Stack>
) : (
<Text size="sm" c="dimmed">
{emptyMessage}
</Text>
)}
{children && (
<>
<Divider />
{children}
</>
)}
</Stack>
</Card>
);
}
export default PersonCard;

View File

@@ -24,4 +24,9 @@ export {
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export {
PersonCard,
type PersonCardProps,
type PersonField,
} from "./PersonCard";
export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -19,6 +19,7 @@ import {
PackageOpen,
Paperclip,
Receipt,
Stamp,
ScrollText,
Send,
Settings,
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
permission: FREIGHT_PERMS.settings.dropdown.view,
},
{
label: "Stamp settings",
// One entry, one stamp. The former "Stamp settings" entry here pointed
// at the per-officer teeter (ማህተም), not a company seal — it moved to
// /user-management/teeter-and-signature.
label: "Company stamp",
href: "/dashboard/stamp-settings",
icon: <FileSignature />,
icon: <Stamp />,
permission: FREIGHT_PERMS.settings.stamp.view,
},
{
label: "Invoice stamp",
href: "/dashboard/invoice-stamp-settings",
icon: <Receipt />,
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { Mail, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in backoffice user change their own email. Goes through
* /me/contact/otp + /me/contact rather than the generic (unverified)
* /auth/update-profile route, so the new address is proven before it's
* written — see account.controller.ts on the API side.
*/
export function ChangeEmailCard() {
const { user } = useAuth();
const sendOtpMutation = useMutation(
api.account.sendContactOtp.mutationOptions(),
);
const updateContactMutation = useMutation(
api.account.updateContact.mutationOptions(),
);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail");
const [newEmail, setNewEmail] = useState("");
const [otp, setOtp] = useState("");
const [formError, setFormError] = useState("");
const closeDialog = () => {
setOpen(false);
setStep("enterEmail");
setNewEmail("");
setOtp("");
setFormError("");
};
const sendOtp = () => {
setFormError("");
if (!newEmail.trim()) {
setFormError("Enter the new email address.");
return;
}
sendOtpMutation.mutate(
{ channel: "email", value: newEmail.trim() },
{
onSuccess: (result) => {
toast.success(`Verification code sent to ${result.sentTo}`);
setStep("enterOtp");
},
},
);
};
const confirmOtp = () => {
setFormError("");
if (!otp.trim()) {
setFormError("Enter the verification code.");
return;
}
updateContactMutation.mutate(
{ channel: "email", value: newEmail.trim(), otp: otp.trim() },
{
onSuccess: () => {
toast.success("Email updated.");
closeDialog();
// Refetches the session so the new email shows everywhere — simplest
// way to refresh the cached user without a dedicated context method.
window.location.reload();
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="size-4" />
Email
</CardTitle>
<CardDescription>
{user?.email ? `Current email: ${user.email}` : "Change your account email."}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Change email
</Button>
</CardContent>
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change email</DialogTitle>
<DialogDescription>
{step === "enterEmail"
? "We'll send a verification code to the new address."
: `Enter the code sent to ${newEmail}.`}
</DialogDescription>
</DialogHeader>
{step === "enterEmail" ? (
<div className="space-y-2">
<Label htmlFor="newEmail">New email</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="otp">Verification code</Label>
<Input
id="otp"
value={otp}
onChange={(e) => setOtp(e.target.value)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
{step === "enterEmail" ? (
<Button disabled={sendOtpMutation.isPending} onClick={sendOtp}>
{sendOtpMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Send code"
)}
</Button>
) : (
<Button disabled={updateContactMutation.isPending} onClick={confirmOtp}>
{updateContactMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Confirm"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import { KeyRound, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in backoffice user change their own password. The account
* is logged out on success — the old token was issued under the old
* password, and this forces a clean re-login rather than trusting the
* server to keep the existing session valid.
*/
export function ChangePasswordCard() {
const { logout } = useAuth();
const changePasswordMutation = useMutation(
api.account.changePassword.mutationOptions(),
);
const [open, setOpen] = useState(false);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [formError, setFormError] = useState("");
const closeDialog = () => {
setOpen(false);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
setFormError("");
};
const submit = () => {
setFormError("");
if (!oldPassword || !newPassword || !confirmPassword) {
setFormError("All fields are required.");
return;
}
if (newPassword.length < 8) {
setFormError("New password must be at least 8 characters.");
return;
}
if (newPassword === oldPassword) {
setFormError("New password must be different from the current one.");
return;
}
if (newPassword !== confirmPassword) {
setFormError("New password and confirmation do not match.");
return;
}
changePasswordMutation.mutate(
{ oldPassword, newPassword, confirmPassword },
{
onSuccess: () => {
toast.success("Password changed. Please sign in again.");
closeDialog();
setTimeout(logout, 1200);
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
Password
</CardTitle>
<CardDescription>Change the password for your account.</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Change password
</Button>
</CardContent>
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
You'll be signed out and asked to log in again once it's changed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="oldPassword">Current password</Label>
<Input
id="oldPassword"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm new password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button disabled={changePasswordMutation.isPending} onClick={submit}>
{changePasswordMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Change password"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -319,7 +319,7 @@ export const TopBar = () => {
{t("header.viewProfile")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
<Key className="w-4 h-4 mr-3" />
{t("header.changePassword")}

View File

@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
// The ONE company stamp/seal, applied to every generated document
// (invoices, receipts, warehouse papers, the EDR side of contracts).
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
// gate the company stamp before the two were untangled.
teeter: {
view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",

View File

@@ -502,7 +502,7 @@ const Header = () => {
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
>
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium">

View File

@@ -38,7 +38,10 @@ import {
} from "@/hooks/contract-templates/useContractTemplates";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { cargoTypesService } from "@/services/cargo-types.service";
import type { ContractTemplate } from "@/services/contract-templates.service";
import type {
BulkTemplateDirection,
ContractTemplate,
} from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
const DIRECTION_LABEL: Record<string, string> = {
@@ -68,6 +71,14 @@ function customsVariant(template: ContractTemplate): boolean | null {
return null;
}
// Bulk templates carry the direction on the row; the fixed container codes
// carry it as the code prefix.
function directionOf(template: ContractTemplate): string {
return isBulk(template)
? template.tradeDirection ?? "INTERCITY"
: template.code.split("_")[0];
}
function formatUpdated(value: string): string {
return new Date(value).toLocaleDateString("en-GB", {
day: "numeric",
@@ -105,7 +116,7 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
action={
canCreate ? (
<Button
@@ -194,9 +205,13 @@ export default function ContractTemplatesPage() {
}
/**
* Staff pick the customs option first, then a bulk cargo type that has
* "has contract template" enabled. One template per combination — the API
* rejects duplicates, so an existing pairing must be edited instead.
* Staff pick the trade direction, then the customs option, then a bulk cargo
* type that has "has contract template" enabled. One template per
* (direction, customs, cargo type) combination — the API rejects duplicates,
* so an existing combination must be edited instead.
*
* Intercity is domestic and crosses no border, so the customs choice does not
* apply there and is hidden.
*/
function CreateTemplateModal({
opened,
@@ -207,9 +222,11 @@ function CreateTemplateModal({
onClose: () => void;
onCreated: (code: string) => void;
}) {
const [direction, setDirection] = useState<BulkTemplateDirection>("IMPORT");
const [withCustoms, setWithCustoms] = useState<string>("true");
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const create = useCreateContractTemplate();
const intercity = direction === "INTERCITY";
const { data: cargoTypes, isLoading } = useQuery({
queryKey: ["cargo-types", "contract-template-options"],
@@ -238,19 +255,42 @@ function CreateTemplateModal({
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
Trade direction
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
value={direction}
onChange={(value) => setDirection(value as BulkTemplateDirection)}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "INTERCITY", label: "Intercity" },
]}
/>
</div>
{intercity ? (
<Text size="xs" c="dimmed">
Intercity contracts are domestic and cross no border, so they have
no customs clearing variant one template per cargo type.
</Text>
) : (
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
]}
/>
</div>
)}
<Select
label="Bulk cargo type"
description="Only cargo types with “has contract template” enabled are listed"
@@ -273,7 +313,12 @@ function CreateTemplateModal({
onClick={() => {
if (!cargoTypeId) return;
create.mutate(
{ cargoTypeId, withCustoms: withCustoms === "true" },
{
cargoTypeId,
tradeDirection: direction,
// Omitted for intercity — the API rejects the flag there.
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
},
{
onSuccess: (template) =>
onCreated((template as ContractTemplate).code),
@@ -305,10 +350,12 @@ function TemplateCard({
onDelete: () => void;
}) {
const bulk = isBulk(template);
const direction = template.code.split("_")[0];
const direction = directionOf(template);
const customs = customsVariant(template);
const kicker = bulk
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · ${
DIRECTION_LABEL[direction] ?? direction
} · Bulk`
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
return (
@@ -336,9 +383,8 @@ function TemplateCard({
style={{
borderRadius: 999,
flexShrink: 0,
background: bulk
? "var(--mantine-color-teal-5)"
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
background:
DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
}}
/>
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">

View File

@@ -8,7 +8,7 @@ import {
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
@@ -21,6 +21,7 @@ import {
ArrowLeft,
ArrowRight,
Banknote,
Contact,
Download,
Eye,
FileText,
@@ -32,6 +33,8 @@ import {
FilePen,
Paperclip,
Receipt,
UserCheck,
UserRound,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
@@ -46,6 +49,7 @@ import {
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
PersonCard,
ProfileApprovalActions,
ProfileChips,
ProfileStatusBadge,
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
>
<Eye size={14} />
</ActionIcon>
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
type="button"
size="xs"
lineClamp={1}
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
style={{
maxWidth: 170,
textAlign: "left",
@@ -615,7 +623,10 @@ export default function CustomerDetailPage() {
[],
);
const licenseProfiles = (company?.companyProfiles ?? []).filter(
/** Never read `company.companyProfiles` directly — an endpoint that stops
* loading the relation would otherwise crash the whole page. */
const profiles = company?.companyProfiles ?? [];
const licenseProfiles = profiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
[documents],
);
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
const poaFields = [
{ label: "PoA name", value: company?.poaName },
{ label: "PoA email", value: company?.poaEmail },
{ label: "PoA phone", value: company?.poaPhone },
{ label: "PoA location", value: company?.poaLocation },
{ label: "PoA address", value: company?.poaAddress },
];
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
const hasPoaDetails = [
company?.poaName,
company?.poaEmail,
company?.poaPhone,
company?.poaLocation,
company?.poaAddress,
].some((v) => v?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces.
const identityState = company?.identity;
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional.
const poaMandatory = (company?.companyProfiles ?? []).some(
(p) => p.type === "freight_forwarder",
);
const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
const delegationMissing =
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
}`}
subtitle={`TIN ${company.tin}${
company.country ? ` · ${company.country}` : ""
}`}
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
items={[
{
label: "Profiles",
value: company.companyProfiles.length,
value: profiles.length,
icon: IdCard,
color: "edr-green",
},
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
: "Pending approval",
value: stillOnboarding
? "—"
: company.companyProfiles.filter(
(p) => p.status === "pending",
).length,
: profiles.filter((p) => p.status === "pending").length,
icon: IdCard,
color: "yellow",
},
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
]}
/>
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Company information
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="TIN" value={company.tin} />
<InfoField label="VAT number" value={company.vatNumber} />
<InfoField label="FAN number" value={company.fanNumber} />
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}
/>
<InfoField
label="Approved on"
value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/>
<InfoField
label="Owner identity"
value={
ownerIdentity?.verified
? "Fayda verified"
: ownerIdentity?.passportNumber
? `Passport ${ownerIdentity.passportNumber}`
: "Not verified"
}
/>
<InfoField label="Country" value={company.country} />
<InfoField
label="Nationality"
value={
company.nationality
? humanize(company.nationality)
: undefined
}
/>
{/* Why this company's registration was typed rather than
fetched, and why it carries no business licence. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} />
<Box />
<InfoField
label="Contact person"
value={company.contactPersonName}
/>
<InfoField
label="Contact phone"
value={company.contactPersonPhone}
/>
<Box />
<InfoField label="Owner" value={company.ownerName} />
<InfoField label="Owner email" value={company.ownerEmail} />
<InfoField label="Owner phone" value={company.ownerPhone} />
</SimpleGrid>
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
)}
</Group>
{hasEtradeRecord && (
<ActionIcon
variant="default"
aria-label="Download TIN record"
onClick={() => downloadTinRecord(company)}
>
<Download size={16} />
</ActionIcon>
)}
</Group>
{hasEtradeRecord ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField
label="License number"
value={company.licenceNumber}
/>
<InfoField label="Status" value={company.statusDescription} />
<InfoField
label="Date registered"
value={company.dateRegistered}
/>
<InfoField label="Renewed from" value={company.renewedFrom} />
<InfoField label="Renewal date" value={company.renewalDate} />
<InfoField label="Renewed to" value={company.renewedTo} />
<InfoField label="Region" value={company.region} />
<InfoField label="Zone" value={company.zone} />
<InfoField label="Woreda" value={company.woreda} />
<InfoField label="Kebele" value={company.kebele} />
<InfoField label="House No" value={company.houseNo} />
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No eTrade registration record on file for this customer's
TIN.
</Text>
)}
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
Owner identity
</Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light">
Fayda verified
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
Not verified
</Badge>
)}
</Group>
{/* THE check: is the owner the company put forward the person
the eTrade licence actually names? Advisory — eTrade and
Fayda transliterate Amharic names differently, so this is a
prompt to look, not a verdict. */}
{identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>, but this
company recorded <strong>{company.ownerName}</strong>.
</Alert>
) : identityState?.ownerMatchesEtrade === true ? (
<Badge
size="sm"
color="edr-green"
variant="light"
style={{ alignSelf: "flex-start" }}
>
Matches the eTrade licence
</Badge>
) : company.cooperative ? (
<Text size="xs" c="dimmed">
A co-operative union or farm holds no trade licence, so
there is no eTrade record to check the owner against.
</Text>
) : (
<Text size="xs" c="dimmed">
No eTrade manager name on file to compare against.
</Text>
)}
{ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} />
<InfoField label="Phone" value={ownerIdentity.phone} />
<InfoField label="Email" value={ownerIdentity.email} />
<InfoField label="Address" value={ownerIdentity.address} />
<InfoField
label="Verified at"
value={formatDate(ownerIdentity.verifiedAt)}
/>
<InfoField
label="Birthdate"
value={ownerIdentity.birthdate}
/>
<InfoField label="Gender" value={ownerIdentity.gender} />
<InfoField
label="Passport number"
value={ownerIdentity.passportNumber}
/>
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
{ownerIdentity?.passportNumber
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
: "The company owner has not verified their identity with Fayda."}
</Text>
)}
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
Power of Attorney
</Text>
{poaMandatory && (
<Badge size="xs" color="blue" variant="light">
Required for freight forwarder
</Badge>
)}
</Group>
{delegationMissing ? (
<Badge size="sm" color="red" variant="light">
DARS delegation paper missing
</Badge>
) : poaLive.length > 0 ? (
<Badge size="sm" color="edr-green" variant="light">
DARS delegation paper on file
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
Not provided
</Badge>
)}
</Group>
{hasPoaDetails ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{poaFields.map((f) => (
<InfoField
key={f.label}
label={f.label}
value={f.value}
/>
))}
<InfoField
label="PoA Fayda"
value={
poaIdentity?.verified ? "Verified" : "Not verified"
}
/>
{poaIdentity?.verified && (
<>
<Grid gap="lg" align="flex-start">
{/* Company facts — the wide column. People live in the narrow one
beside it, so nothing about a person is stated twice. */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Company information
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<InfoField label="TIN" value={company.tin} />
<InfoField
label="PoA verified at"
value={formatDate(poaIdentity.verifiedAt)}
label="VAT number"
value={company.vatNumber}
/>
<InfoField label="Country" value={company.country} />
<InfoField
label="Nationality"
value={
company.nationality
? humanize(company.nationality)
: undefined
}
/>
{/* Why this company's registration was typed rather
than fetched, and why it carries no licence. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} />
<InfoField label="Website" value={company.website} />
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}
/>
<InfoField
label="PoA birthdate"
value={poaIdentity.birthdate}
label="Approved on"
value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/>
<InfoField label="PoA gender" value={poaIdentity.gender} />
</>
)}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No Power of Attorney representative recorded for this
customer.
</Text>
)}
</SimpleGrid>
</Stack>
</Card>
<Divider />
<Stack gap="sm">
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
DARS delegation paper
</Text>
{documentsQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading documents
</Text>
</Group>
) : documentsQuery.isError ? (
<Group gap="sm">
<Text size="sm" c="red">
Failed to load documents.
</Text>
<Anchor
component="button"
type="button"
size="xs"
onClick={() => void documentsQuery.refetch()}
>
Retry
</Anchor>
</Group>
) : poaDocuments.length === 0 ? (
<Text size="sm" c="dimmed">
No DARS delegation paper uploaded.
</Text>
) : (
poaDocuments.map((doc) => (
<Group key={doc.id} justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Paperclip
size={14}
className="shrink-0 text-edr-muted"
/>
<Anchor
component="button"
type="button"
size="sm"
lineClamp={1}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(view)
}
>
{doc.name}
</Anchor>
<Text size="xs" c="dimmed" className="shrink-0">
{formatBytes(doc.size)} ·{" "}
{formatDate(doc.uploadedAt)}
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{doc.code === POA_DELEGATION_PENDING_CODE && (
<Badge
size="xs"
color="yellow"
variant="light"
className="shrink-0"
>
Pending approval
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{hasEtradeRecord && (
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(view)
}
>
<Eye size={16} />
</ActionIcon>
<ActionIcon
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.id, doc.name)
}
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
variant="default"
aria-label="Download TIN record"
onClick={() => downloadTinRecord(company)}
>
<Download size={16} />
</ActionIcon>
</Group>
)}
</Group>
))
)}
</Stack>
</Stack>
</Card>
{hasEtradeRecord ? (
<SimpleGrid
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="lg"
>
<InfoField
label="License number"
value={company.licenceNumber}
/>
<InfoField
label="Status"
value={company.statusDescription}
/>
<InfoField
label="Date registered"
value={company.dateRegistered}
/>
<InfoField
label="Renewed from"
value={company.renewedFrom}
/>
<InfoField
label="Renewal date"
value={company.renewalDate}
/>
<InfoField
label="Renewed to"
value={company.renewedTo}
/>
<InfoField label="Region" value={company.region} />
<InfoField label="Zone" value={company.zone} />
<InfoField label="Woreda" value={company.woreda} />
<InfoField label="Kebele" value={company.kebele} />
<InfoField label="House No" value={company.houseNo} />
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No eTrade registration record on file for this
customer's TIN.
</Text>
)}
</Stack>
</Card>
<Card>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} c="edr-text">
Role profiles
</Text>
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={1040}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
</Box>
</Stack>
</Card>
<Card>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} c="edr-text">
Role profiles
</Text>
<ProfileChips profiles={profiles} />
</Group>
{/* Narrower than the old full-width layout — the table
shares the row with the people column now. */}
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={760}>
<DataTable
columns={profileColumns}
data={profiles}
status="success"
emptyMessage="No profiles registered."
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
</Box>
</Stack>
</Card>
</Stack>
</Grid.Col>
{/* People: owner, then power of attorney, then contact person —
the order a reviewer checks them in. */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
<PersonCard
title="Owner"
icon={<UserRound size={15} className="text-edr-muted" />}
verified={Boolean(ownerIdentity?.verified)}
badges={
<>
{identityState?.subject === "owner" && (
<Badge size="xs" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{identityState?.ownerMatchesEtrade === true && (
<Badge size="xs" color="edr-green" variant="light">
Matches eTrade licence
</Badge>
)}
</>
}
notice={
/* THE check: is the owner the company put forward the
person the eTrade licence actually names? Advisory —
eTrade and Fayda transliterate Amharic names
differently, so this is a prompt to look, not a
verdict. */
identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
p="xs"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
<Text size="xs">
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>,
but this company recorded{" "}
<strong>{company.ownerName ?? "nobody"}</strong>.
</Text>
</Alert>
) : !ownerIdentity?.verified &&
ownerIdentity?.passportNumber ? (
<Text size="xs" c="dimmed">
Identified by passport rather than Fayda.
</Text>
) : null
}
fields={[
{
label: "Name",
value: company.ownerName ?? ownerIdentity?.name,
},
{
label: "Email",
value: company.ownerEmail ?? ownerIdentity?.email,
},
{
label: "Phone",
value: company.ownerPhone ?? ownerIdentity?.phone,
},
{
label: "Passport number",
value: ownerIdentity?.passportNumber,
},
{ label: "Address", value: ownerIdentity?.address },
{ label: "Birthdate", value: ownerIdentity?.birthdate },
{ label: "Gender", value: ownerIdentity?.gender },
{
label: "Verified on",
value: ownerIdentity?.verifiedAt
? formatDate(ownerIdentity.verifiedAt)
: null,
},
]}
emptyMessage="No owner recorded for this company."
/>
<PersonCard
title="Power of attorney"
icon={<UserCheck size={15} className="text-edr-muted" />}
verified={
// No PoA at all → no badge, rather than a "not verified"
// that reads as a problem where none exists.
hasPoaDetails || identityState?.poaDeclared === "yes"
? Boolean(poaIdentity?.verified)
: undefined
}
badges={
<>
{identityState?.subject === "poa" && (
<Badge size="xs" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{poaMandatory && (
<Badge size="xs" color="blue" variant="light">
Required for freight forwarder
</Badge>
)}
{delegationMissing && (
<Badge size="xs" color="red" variant="light">
Delegation paper missing
</Badge>
)}
</>
}
fields={[
{ label: "Name", value: company.poaName },
{ label: "Email", value: company.poaEmail },
{ label: "Phone", value: company.poaPhone },
{ label: "Location", value: company.poaLocation },
{ label: "Address", value: company.poaAddress },
{ label: "Birthdate", value: poaIdentity?.birthdate },
{ label: "Gender", value: poaIdentity?.gender },
{
label: "Verified on",
value: poaIdentity?.verifiedAt
? formatDate(poaIdentity.verifiedAt)
: null,
},
]}
emptyMessage="No representative recorded for this customer."
>
<Stack gap="xs">
<Text size="xs" c="edr-muted">
DARS delegation paper
</Text>
{documentsQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading
</Text>
</Group>
) : documentsQuery.isError ? (
<Group gap="sm">
<Text size="sm" c="red">
Failed to load documents.
</Text>
<Anchor
component="button"
type="button"
size="xs"
onClick={() => void documentsQuery.refetch()}
>
Retry
</Anchor>
</Group>
) : poaDocuments.length === 0 ? (
<Text size="sm" c="dimmed">
Not uploaded.
</Text>
) : (
poaDocuments.map((doc) => (
<Stack key={doc.id} gap={2}>
<Group gap={6} wrap="nowrap">
<Paperclip
size={13}
className="shrink-0 text-edr-muted"
/>
<Anchor
component="button"
type="button"
size="sm"
lineClamp={1}
style={{ flex: 1, textAlign: "left" }}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
}
>
{doc.name}
</Anchor>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
}
>
<Eye size={15} />
</ActionIcon>
<ActionIcon
size="sm"
component="button"
type="button"
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
onClick={() =>
void downloadBookingFile(doc.id, doc.name)
}
>
<Download size={15} />
</ActionIcon>
</Group>
<Group gap={6} pl={19} wrap="wrap">
<Text size="xs" c="dimmed">
{formatBytes(doc.size)} ·{" "}
{formatDate(doc.uploadedAt)}
</Text>
{doc.code === POA_DELEGATION_PENDING_CODE && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
</Group>
</Stack>
))
)}
</Stack>
</PersonCard>
<PersonCard
title="Contact person"
icon={<Contact size={15} className="text-edr-muted" />}
fields={[
{ label: "Name", value: company.contactPersonName },
{ label: "Phone", value: company.contactPersonPhone },
]}
emptyMessage="No contact person recorded."
/>
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Tabs.Panel>
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
: undefined
}
/>
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
: undefined
}
/>
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
: undefined
}
pagination={{

View File

@@ -1,8 +1,12 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard";
export default function MyProfilePage() {
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
<ChangeEmailCard />
<ChangePasswordCard />
<div id="signature">
<MySignatureCard />
</div>

View File

@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** When true, bookings of this cargo type incur the lane-scoped FUEL surcharge. */
hasFuel?: boolean;
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
@@ -114,6 +116,9 @@ const FORM_FIELDS: FormFieldDef[] = [
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
// When on, bookings of this cargo type incur the fuel surcharge, billed off
// the lane-scoped FUEL rate (direction + route + cargo type) under Rates.
{ name: "hasFuel", label: "Charge fuel fee", type: "boolean" },
// Lets staff write bulk contract templates for this cargo type. The API
// rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level.

View File

@@ -100,19 +100,27 @@ const yardOptionsForLegEnd = (
} else if (
appliesTo === "CONTAINER" ||
appliesTo === "BULK" ||
// Customs clearance + empty-container return are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
// Customs clearance, empty-container return and fuel are sold per
// direction + route, so their yard dropdowns narrow exactly like base
// freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
if (direction === "DOMESTIC") {
// A fuel rate's intercity lane — stays inside Ethiopia.
country = "Ethiopia";
} else {
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
}
}
if (!country) return [];
return yards

View File

@@ -181,6 +181,17 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
];
/**
* Fuel lanes: import/export like base freight, plus a domestic intercity lane
* (stored as DOMESTIC — matches the booking's own trade direction).
*/
const FUEL_TRADE_DIRECTIONS = [
{ label: "Import", value: "IMPORT" },
{ label: "Export", value: "EXPORT" },
{ label: "Intercity", value: "DOMESTIC" },
];
/**
@@ -205,7 +216,7 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -255,6 +266,9 @@ const unitsForShape = (
case "LASHING":
// Bulk-only cargo securing — per ton or per wagon.
return ["PER_TON", "PER_WAGON"];
case "FUEL":
// Per wagon (wagons × rate) or per liter (base liters × rate, once).
return ["PER_WAGON", "PER_LITER"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -371,6 +385,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{
name: "hasFuel",
label: "Charge fuel fee",
type: "boolean",
description:
"Bookings of this cargo incur the fuel surcharge (configure the FUEL rate per lane under Rates).",
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -834,7 +855,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
},
},
],
@@ -886,14 +907,17 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: (v: Record<string, unknown>) =>
String(v.trigger ?? "") === "WITH_RETURN" &&
String(v.appliesTo ?? "") === "OTHER"
String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN"
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
: String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "FUEL"
? FUEL_TRADE_DIRECTIONS
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""),
)),
},
@@ -939,6 +963,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK",
},
// ── Cargo type — a fuel rate names the commodity it covers (different
// commodities price differently on the same lane) ──────────────────────
{
name: "cargoTypeId",
label: "Cargo type",
type: "select",
required: true,
placeholder: "Which cargo type this fuel rate covers",
description:
"Fuel is charged for bookings of this cargo type (needs “Charge fuel fee” enabled on the cargo type).",
showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL",
},
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ──────────
{
@@ -1123,6 +1159,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
String(v.trigger ?? "") !== "OVERWEIGHT" &&
String(v.appliesTo ?? "") !== "LAST_MILE",
},
// ── Base liters — per-liter fuel rates only ────────────────────────────
{
name: "baseLiters",
label: "Base (liters)",
type: "number",
required: true,
placeholder: "e.g. 100",
description:
"Liters the surcharge covers — price = base liters × rate value, charged once per booking.",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "FUEL" &&
v.rateUnit === "PER_LITER",
},
],
},
{

View File

@@ -17,10 +17,16 @@ import {
} from "@/hooks/useStampSettings";
/**
* The one company stamp/seal stamped onto every generated invoice/receipt
* PDF (InvoiceDocumentService). Single global image no per-employee choice.
* The ONE company stamp/seal, read by every document path server-side via
* StampSettingsService: invoices and receipts (InvoiceDocumentService),
* warehouse release + handover papers, and the EDR side of contract signature
* blocks. Single global image no per-employee choice, and staff never upload
* one when signing.
*
* Not to be confused with the per-officer teeter () at
* /user-management/teeter-and-signature, which is genuinely per-person.
*/
export default function InvoiceStampSettingsPage() {
export default function CompanyStampSettingsPage() {
const { data, isLoading } = useStampSettingsQuery();
const setStamp = useSetStamp();
const clearStamp = useClearStamp();
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Invoice stamp</CardTitle>
<CardTitle>Company stamp</CardTitle>
<CardDescription>
Stamped onto every generated invoice and receipt PDF. Replacing it
here changes it everywhere at once there is no per-invoice or
per-user choice.
The single EDR seal, applied to every generated document invoices
and receipts, warehouse release and handover papers, and the EDR
side of signed contracts. Replacing it here changes it everywhere at
once; there is no per-document, per-invoice or per-employee choice.
Staff do not upload their own.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">

View File

@@ -1019,14 +1019,11 @@ export default function TrainScheduleV2ListPage() {
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation (never the coupled consist size), and remaining
* excludes wagons reserved by bookings that have not paid yet — that space is
* claimed, so it is not bookable.
*
* Schedules whose train set has not been built yet have no consist to measure,
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
* though every one of its wagons is still free.
* carrying a booking allocation, the denominator is the schedule's capacity
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
* wagons are coupled on demand), and remaining excludes wagons reserved by
* bookings that have not paid yet — that space is claimed, so it is not
* bookable.
*/
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
@@ -1034,17 +1031,7 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
// Until the train set is built there is no consist to measure against, so
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
// which reads as "fully booked" when in fact nothing is booked at all. Before
// a consist exists, capacity is the planned ceiling minus what bookings have
// already claimed.
const planCeiling = schedule.maxWagons ?? 0;
const remaining =
total === 0 && planCeiling > 0
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
: schedule.wagonsRemaining;
const remaining = schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
@@ -1052,11 +1039,9 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
return (
<>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />

View File

@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
>
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.changePassword")}</span>

View File

@@ -0,0 +1,50 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
export type ContactChannel = "email" | "phone";
export interface ChangePasswordPayload {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface SendContactOtpPayload {
channel: ContactChannel;
/** The NEW email/phone to verify — the OTP is sent here, not to the current one. */
value: string;
}
export interface UpdateContactPayload extends SendContactOtpPayload {
otp: string;
}
export const accountService = {
/** PATCH /auth/change-password — generic IAM route, works for any user type. */
changePassword: async (payload: ChangePasswordPayload): Promise<void> => {
const response = await client.patch("/auth/change-password", payload);
unwrap(response.data);
},
/** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */
sendContactOtp: async (
payload: SendContactOtpPayload,
): Promise<{ sentTo: string }> => {
const response = await client.post<{ sentTo: string }>(
"/me/contact/otp",
payload,
);
return unwrap(response.data);
},
/** PATCH /me/contact — verifies the OTP and writes the new email/phone. */
updateContact: async (
payload: UpdateContactPayload,
): Promise<{ success: true; value: string }> => {
const response = await client.patch<{ success: true; value: string }>(
"/me/contact",
payload,
);
return unwrap(response.data);
},
};

View File

@@ -136,6 +136,12 @@ import type {
WarehouseZone,
} from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint";
import {
accountService,
type ChangePasswordPayload,
type SendContactOtpPayload,
type UpdateContactPayload,
} from "./account.service";
import {
BookingListFilter,
bookingsService,
@@ -2182,6 +2188,29 @@ export const api = {
),
},
account: {
changePassword: endpoint<ChangePasswordPayload, void>(
"me",
"change-password",
(payload) => accountService.changePassword(payload),
),
sendContactOtp: endpoint<SendContactOtpPayload, { sentTo: string }>(
"me",
"send-contact-otp",
(payload) => accountService.sendContactOtp(payload),
),
updateContact: endpoint<
UpdateContactPayload,
{ success: true; value: string }
>(
"me",
"update-contact",
(payload) => accountService.updateContact(payload),
),
},
signatures: {
mySignature: endpoint<void, SavedSignature | null>(
"me",

View File

@@ -2,6 +2,10 @@ import { api as client } from "../auth/http";
const BASE = "/contract-templates";
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
export interface ContractTemplateArticle {
id: string;
title: string;
@@ -23,7 +27,12 @@ export interface ContractTemplate {
/** Bulk templates only: the cargo type this template is written for. */
cargoTypeId?: string | null;
cargoType?: { id: string; cargoTypeName: string } | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
tradeDirection?: BulkTemplateDirection | null;
/**
* Bulk templates only: whether this is the with-customs-clearing variant.
* Null for intercity — domestic movements have no customs leg.
*/
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
isSystem: boolean;
@@ -33,7 +42,9 @@ export interface ContractTemplate {
export interface CreateContractTemplatePayload {
cargoTypeId: string;
withCustoms: boolean;
tradeDirection: BulkTemplateDirection;
/** Omitted for INTERCITY — the API rejects the flag there. */
withCustoms?: boolean;
name?: string;
description?: string;
}

View File

@@ -11,6 +11,8 @@ export interface Rate {
currency: string;
rateValue: string;
rateUnit: string;
/** PER_LITER fuel rates only: price = baseLiters × rateValue, once per booking. */
baseLiters?: string | null;
status: string;
proposedByStaffId: string;
approvedByCeoId: string | null;

View File

@@ -9,7 +9,8 @@ export type EimsInvoiceStatus =
| "SUBMITTING"
| "REGISTERED"
| "FAILED"
| "UNKNOWN";
| "UNKNOWN"
| "CANCELLED";
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
export interface EimsInvoiceError {
@@ -30,6 +31,28 @@ export interface EimsInvoiceStatusView {
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
/** Base64 PNG from MoR, already rendered on their side — embed as `data:image/png;base64,...`. */
eimsSignedQr: string | null;
eimsCancelledAt: string | null;
/** MoR's own confirmation string (a Java `Date#toString()`), stored verbatim — display as-is. */
eimsCancellationDate: string | null;
eimsCancellationReasonCode: string | null;
eimsCancellationRemark: string | null;
}
/** One `EimsReceipt` row — mirrors `entities/eims-receipt.entity.ts`. */
export interface EimsReceiptView {
id: string;
invoiceId: string;
kind: "SALES" | "WITHHOLDING";
status: EimsInvoiceStatus;
receiptNumber: string;
rrn: string | null;
/** Base64 PNG, same convention as `eimsSignedQr`. */
qr: string | null;
ackStatus: string | null;
submittedAt: string | null;
lastError: EimsInvoiceError | null;
}
/** `POST /v1/verify` response, echoed back from the gateway. */

View File

@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
import { isSuperAdmin } from "@/lib/permissions";
import { FREIGHT_PERMS, isSuperAdmin } from "@/lib/permissions";
import { WithPermission } from "@/shared/hooks/useHas";
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
import TemplatePage from "@/super-admin/components/templates/components/templates";
@@ -44,6 +44,8 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
import { PermissionProvider } from "@/shared/context/PermissionContext";
import UserManagementPage from "@/pages/UserManagementPage";
import { RequirePermission } from "@/components/auth/RequirePermission";
import UploadTeeterAndSignature from "@/record-management/components/Settings/uploadTeeterandSingature";
/**
* Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts
@@ -138,6 +140,29 @@ export function UserManagementRoutes(): ReactElement {
path="user-management/position-management"
element={<PositionManagementPage />}
/>
{/*
The per-officer teeter (ማህተም) + signature upload. This
is NOT the company stamp: it is the individual approval
stamp a record officer applies to records, locale-aware
(am/en) and genuinely per-person. It used to sit at
/dashboard/stamp-settings under Settings, next to the
single global company stamp, which read as duplication.
Gated by settings:teeter:*, split out of settings:stamp:*
when the two were untangled — settings:stamp:* now means
the company stamp, so anyone who held it for the teeter
needs the new key granted.
*/}
<Route
path="user-management/teeter-and-signature"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.teeter.view}
>
<UploadTeeterAndSignature />
</RequirePermission>
}
/>
<Route
path="user-management/migrated-records-management"
element={<MigratedDataManagementPage />}

View File

@@ -169,7 +169,8 @@ export default function OnboardingWizardDialog({
setCooperative(checked);
if (checked) {
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
// Ethiopian is then the only answer left, so it is made rather than asked.
setNationality("ethiopian");
}
}, []);
const [documentFiles, setDocumentFiles] = useState<

Some files were not shown because too many files have changed in this diff Show More