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 # 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 # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set. # 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 # 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_CODE=
EIMS_TAX_RATE_PERCENT=0 EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0 EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_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. # Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B EIMS_TRANSACTION_TYPE=B2B
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. # 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 { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module"; import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { RequestLogMiddleware } from "@edr/api-common"; import { RequestLogMiddleware } from "@edr/api-common";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -244,6 +245,7 @@ if (!process.env.APPLICATION_NAME) {
EimsModule, EimsModule,
FleetHistoryModule, FleetHistoryModule,
AiModule, AiModule,
AuditModule,
], ],
providers: [ providers: [
EdrOrgSeeder, EdrOrgSeeder,

View File

@@ -1,5 +1,7 @@
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import type { Repository } from "typeorm";
import { import {
BaseRepository,
RequestLogMiddleware, RequestLogMiddleware,
getLogContext, getLogContext,
logCtx, logCtx,
@@ -47,10 +49,13 @@ describe("logCtx", () => {
describe("RequestLogMiddleware", () => { describe("RequestLogMiddleware", () => {
it("emits one canonical JSON line carrying the collected context", () => { 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[] = []; const lines: string[] = [];
jest jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
.spyOn(Logger.prototype, "warn") lines.push(String(chunk));
.mockImplementation((m) => lines.push(String(m))); return true;
});
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record<string, () => void> = {}; const listeners: Record<string, () => void> = {};
@@ -60,10 +65,40 @@ describe("RequestLogMiddleware", () => {
originalUrl: "/api/bookings/1/submit?dry=1", originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings", baseUrl: "/api/bookings",
route: { path: "/:id/submit" }, 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", ip: "10.0.0.1",
query: { dry: "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 = { const res = {
statusCode: 409, statusCode: 409,
@@ -82,7 +117,11 @@ describe("RequestLogMiddleware", () => {
listeners.close(); // aborts/close after finish must not double-log listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1); expect(lines).toHaveLength(1);
expect(lines[0].endsWith("\n")).toBe(true);
expect(lines[0].startsWith("{")).toBe(true);
expect(JSON.parse(lines[0])).toMatchObject({ expect(JSON.parse(lines[0])).toMatchObject({
level: "warn",
logger: "request",
type: "http_request", type: "http_request",
requestId: "req-42", requestId: "req-42",
method: "POST", method: "POST",
@@ -96,7 +135,65 @@ describe("RequestLogMiddleware", () => {
bookingId: "b-1", bookingId: "b-1",
booking: { outcome: "REJECTED" }, 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"); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks(); 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>; buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>; 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; cashierName: string | null;
salesPersonName: 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 = [ const REQUIRED_VARS = [
@@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => {
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_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, cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_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_CONTAINER: 'per container',
PER_KM: 'per km', PER_KM: 'per km',
PER_TON_KM: 'per ton per km', PER_TON_KM: 'per ton per km',
PER_LITER: 'per liter',
PER_INVOICE: 'per invoice', PER_INVOICE: 'per invoice',
FLAT: 'flat', FLAT: 'flat',
}; };
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention', DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee', PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service', CUSTOMS_CLEARANCE: 'Customs clearance service',
FUEL: 'Fuel surcharge',
}; };
@Injectable() @Injectable()
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
continue; 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). // Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate)); 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 { private surchargeRow(rate: Rate): RateScheduleRow {
return { return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), 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, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config
); );
}); });
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config
); );
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config
); );
return { service, mg, events }; return { service, mg, events };
} }
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // companies {} as never, // companies
{} as never, // invoiceDocuments {} as never, // invoiceDocuments
{} as never, // files {} as never, // files
{ get: () => undefined } as never, // config
); );
return { service, mg, events }; 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,
{} as never, {} as never,
{} as never, // config
); );
return { service, defaultManager, txManager, transaction }; return { service, defaultManager, txManager, transaction };
}; };
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config
); );
return { service, manager }; return { service, manager };
}; };
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config
); );
return { service, repo }; return { service, repo };
}; };
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never, // config
); );
return { service, repo }; 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 { Freight, PaymentReferenceType } from "@edr/types";
import { ConfigService } from "@nestjs/config";
import { import {
BadRequestException, BadRequestException,
forwardRef, forwardRef,
@@ -8,9 +9,11 @@ import {
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter"; import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm"; import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service"; import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service"; import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
@@ -160,6 +163,7 @@ export class BillingService {
private readonly companies: CompaniesService, private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService, private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService, private readonly files: FilesService,
private readonly config: ConfigService,
) { } ) { }
// ── Reads ────────────────────────────────────────────────────────────────── // ── Reads ──────────────────────────────────────────────────────────────────
@@ -383,7 +387,7 @@ export class BillingService {
async document(id: string): Promise<{ filename: string; buffer: Buffer }> { async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); const invoice = await this.findById(id);
return this.invoiceDocuments.render( return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"), await this.toDocumentModel(invoice, "INVOICE"),
); );
} }
@@ -396,15 +400,24 @@ export class BillingService {
); );
} }
return this.invoiceDocuments.render( 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. */ /** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel( private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] }, invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT", kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel { ): Promise<InvoiceDocumentModel> {
const title = invoice.source const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR"; : "EDR";
@@ -422,6 +435,43 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); 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 { return {
kind, kind,
title, title,
@@ -429,24 +479,7 @@ export class BillingService {
issuedAt: invoice.issuedAt ?? invoice.createdAt, issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status, status: invoice.status,
currency: invoice.currency, currency: invoice.currency,
summary: [ 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,
},
],
categoryHeader: "Charge type", categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({ lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType, description: l.description ?? l.chargeType,
@@ -457,6 +490,7 @@ export class BillingService {
currency: l.currency, currency: l.currency,
})), })),
totals, 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 }); 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; const updated = { ...invoice, ...extra, status } as Invoice;
return { return {
result: updated, result: updated,
@@ -1320,6 +1372,21 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance."); 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 // 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). // required up front (the payment service rejects it otherwise, as a 502 here).
if ( if (
@@ -1443,7 +1510,24 @@ export class BillingService {
// first on DESC, which would hand back an unissued invoice. // first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, 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[] = [ const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES, ...OPEN_STATUSES,
@@ -1453,6 +1537,12 @@ export class BillingService {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled // Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with // inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly. // 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) { if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error( this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + `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. * explicitly only to override that default for one document.
*/ */
stampImageUrl?: string | null; 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 // summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document. // plain-text dump — so it still reads as a proper invoice document.
// ponytail: still draws the plain vector seal, not the uploaded stamp // ponytail: still draws the plain vector seal, not the uploaded stamp
// image — embedding a raster image needs a new PDF XObject primitive // image, and omits the EIMS QR entirely — embedding a raster image
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to // needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
// carry the real stamp too; today it's a rare degraded fallback. // 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), fallback: () => this.buildFallbackPdf(resolvedModel),
}), }),
}; };
@@ -243,6 +251,10 @@ export class InvoiceDocumentService {
const sealInner = sealMarkup(model.stampImageUrl, sealText); const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl); 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 const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`) .map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join(""); .join("");
@@ -281,8 +293,19 @@ export class InvoiceDocumentService {
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .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; } .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()} ${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 { 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; } .summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; } table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; } th { text-align: left; background: #f8fafc; color: #475569; }
@@ -309,7 +332,8 @@ export class InvoiceDocumentService {
</div> </div>
</div> </div>
<div class="${sealCssClass}">${sealInner}</div> <div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div> ${qrMarkup}
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
<table> <table>
<thead> <thead>
<tr> <tr>

View File

@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
salesPersonName: null, salesPersonName: null,
transactionType: "B2B", transactionType: "B2B",
payment: { mode: "CASH", term: "IMMIDIATE" }, payment: { mode: "CASH", term: "IMMIDIATE" },
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }), taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
natureOfSupplies: "Service", natureOfSupplies: "Service",
unitDefault: "PCS", unitDefault: "PCS",
incomeWithholdValue: 0, incomeWithholdValue: 0,
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
context({ context({
taxForLine: (line) => taxForLine: (line) =>
line.chargeType === "RAIL_FREIGHT" line.chargeType === "RAIL_FREIGHT"
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 } ? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 }, : { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
}), }),
); );
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
TaxCode: "VAT15", TaxCode: "VAT15",
TaxAmount: 1500, TaxAmount: 1500,
ExciseTaxValue: 0, ExciseTaxValue: 0,
Discount: 0,
TotalLineAmount: 11500, TotalLineAmount: 11500,
Unit: "PCS", Unit: "PCS",
NatureOfSupplies: "service", NatureOfSupplies: "service",
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
TaxCode: "EXEMPT", TaxCode: "EXEMPT",
TaxAmount: 0, TaxAmount: 0,
ExciseTaxValue: 50, 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, TotalLineAmount: 1050,
Unit: "CTR", Unit: "CTR",
}); });
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
toEimsInvoice( toEimsInvoice(
invoice(), invoice(),
seller, 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/); ).toThrow(/unresolved tax treatment for line 1/);
}); });

View File

@@ -183,6 +183,12 @@ export interface EimsLineTax {
code: string; code: string;
ratePercent: number; ratePercent: number;
exciseTaxValue: 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 { export interface EimsMapperContext {
@@ -336,7 +342,13 @@ export function toEimsInvoice(
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => { const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
const lineNumber = index + 1; const lineNumber = index + 1;
const tax = context.taxForLine(line, lineNumber); 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( throw new Error(
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` + `EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
`on invoice ${invoice.invoiceNumber}`, `on invoice ${invoice.invoiceNumber}`,
@@ -349,7 +361,7 @@ export function toEimsInvoice(
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault; const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
return { return {
Discount: 0, Discount: round2(tax.discount),
ExciseTaxValue, ExciseTaxValue,
HarmonizationCode: null, HarmonizationCode: null,
NatureOfSupplies: natureOfSupplies, NatureOfSupplies: natureOfSupplies,

View File

@@ -111,10 +111,22 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" }) @Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
eimsStatus!: EimsInvoiceStatus; 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; 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. */ /** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true }) @Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null; 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. */ /** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
@Column({ name: "eims_last_error", type: "jsonb", nullable: true }) @Column({ name: "eims_last_error", type: "jsonb", nullable: true })
eimsLastError?: EimsInvoiceError | null; 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 { SchedulingStatus } from '@edr/types';
import { ConflictException, Injectable } from '@nestjs/common'; import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
authorId?: string, authorId?: string,
): Promise<BookingReviewNote> { ): Promise<BookingReviewNote> {
const repo = this.dataSource.getRepository(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( return repo.save(
repo.create({ bookingId, note, type, authorId: authorId ?? null }), repo.create({ bookingId, note, type, authorId: authorId ?? null }),
); );

View File

@@ -9,7 +9,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types'; 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 { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
@@ -2115,6 +2115,21 @@ export class BookingsService {
throw new NotFoundException(`Booking ${id} not found`); 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) { if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all( booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => { 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({ @ApiOperation({
summary: 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) { create(@Body() dto: CreateContractTemplateDto) {
return this.service.create(dto); return this.service.create(dto);

View File

@@ -1,10 +1,13 @@
import { BaseRepository } from "@edr/api-common"; import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { IsNull, Repository } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity"; 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() @Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> { export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
@@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
findByCargoCombo( findByCargoCombo(
cargoTypeId: string, cargoTypeId: string,
withCustoms: boolean, tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
): Promise<ContractTemplate | 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 * The active bulk template covering this cargo type: written against the
* cargo type itself or against its parent group (the two are mutually * 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( findActiveBulkTemplate(
cargoTypeId: string, cargoTypeId: string,
withCustoms: boolean, tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
): Promise<ContractTemplate | null> { ): Promise<ContractTemplate | null> {
return this.repository return this.repository
.createQueryBuilder("t") .createQueryBuilder("t")
.where("t.is_active = true") .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( .andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c SELECT c.parent_group_id FROM freight.cargo_types c

View File

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

View File

@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsIn,
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
@@ -13,6 +14,11 @@ import {
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import {
BULK_TEMPLATE_DIRECTIONS,
BulkTemplateDirection,
} from "../entities/contract-template.entity";
export class CreateContractTemplateDto { export class CreateContractTemplateDto {
@ApiProperty({ @ApiProperty({
description: description:
@@ -23,10 +29,19 @@ export class CreateContractTemplateDto {
cargoTypeId!: string; cargoTypeId!: string;
@ApiProperty({ @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() @IsBoolean()
withCustoms!: boolean; withCustoms?: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional() @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. * template). These are system rows: always present, never deletable.
* *
* Bulk templates are NOT seeded — staff create them per bulk cargo type * Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`) and customs option (`withCustoms`), one template per * (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS. * (`withCustoms`), one template per combination. Their codes are generated by
* The retired direction-keyed bulk codes remain listed so old frozen document * `bulkTemplateCode` below. The retired direction-keyed bulk codes remain
* snapshots still label correctly. * listed so old frozen document snapshots still label correctly.
* *
* Contracts store DOMESTIC for intercity movements; the template layer labels * Contracts store DOMESTIC for intercity movements; the template layer labels
* those INTERCITY to match the commercial vocabulary used on the printed * those INTERCITY to match the commercial vocabulary used on the printed
@@ -82,9 +82,41 @@ export function contractTemplateCodeFor(
return `${direction}_${freight}_${customs}` as ContractTemplateCode; 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" }) @Entity({ schema: "freight", name: "contract_templates" })
// Uniqueness lives in partial DB indexes (live rows only): code, and // 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"]) @Index(["code"])
export class ContractTemplate extends BaseEntity { export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 80 }) @Column({ name: "code", type: "varchar", length: 80 })
@@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity {
@JoinColumn({ name: "cargo_type_id" }) @JoinColumn({ name: "cargo_type_id" })
cargoType?: CargoType | null; 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 }) @Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null; withCustoms?: boolean | null;

View File

@@ -157,13 +157,9 @@ export class ContractBookingService {
actorPermissions != null && actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor); 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 // 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), // booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4). // 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 // The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract. // (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here. // GENERAL customs instances come from a shipment request, not from here.
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor, true); 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 // 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 // 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 // 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.', 'Shipment-request initiation applies only to general customs contracts.',
); );
} }
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { await this.assertNotExpired(contract);
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const route = await this.resolveRoute(contract, opts.contractRouteId); const route = await this.resolveRoute(contract, opts.contractRouteId);
@@ -681,9 +672,11 @@ export class ContractBookingService {
if (!dto.scheduledDate) { if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required'); throw new BadRequestException('A binding shipment day is required');
} }
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { // No expiry gate here on purpose: this booking was already initiated
throw new BadRequestException('Contract validity has expired — no new bookings.'); // 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 — // 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. // 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 * 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. * 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( private async assertGate(
contract: Contract, contract: Contract,
isGlActor: boolean, isGlActor: boolean,

View File

@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
export interface ContractUnitRateLineItem { export interface ContractUnitRateLineItem {
code: string; code: string;
label: 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; unitPrice: number;
containerSize?: string | null; containerSize?: string | null;
conditionalOn?: string | null; conditionalOn?: string | null;
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
return 'per_wagon'; return 'per_wagon';
case 'PER_CONTAINER': case 'PER_CONTAINER':
return 'per_container'; return 'per_container';
case 'PER_LITER':
return 'per_liter';
default: default:
return 'flat'; 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 // Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container. // contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if ( if (

View File

@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Readable } from 'stream'; 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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
): Promise<void> { ): Promise<void> {
const role = dto.role as ContractSignerRole; 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 // 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 // omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied. // saved one whenever no image is supplied.

View File

@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
return result.affected ?? 0; 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 * 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 * now — the slice the daily expiry-reminder cron warns about. The window is

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import { YardCountry } from '@edr/types'; import { YardCountry } from '@edr/types';
// //
@@ -811,6 +811,27 @@ export class ContractsService {
throw new NotFoundException(`Contract ${id} not found`); 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) { if (contract.files && contract.files.length > 0) {
contract.files = await Promise.all( contract.files = await Promise.all(
contract.files.map(async (file: FileRecord) => { 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.", description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0", 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() @IsOptional()
@IsString() @IsString()
@Length(1, 64) @Length(1, 500)
irn?: string; irn?: string;
@ApiPropertyOptional({ @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); 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, salesPersonName: invoice.salesPersonName,
transactionType: invoice.transactionType, transactionType: invoice.transactionType,
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm }, payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
// One treatment for every line today. The mapper resolves tax per line, so a future // Per-`chargeType` override when one is configured (validated symmetric in
// charge-type-specific rule slots in here without touching the mapper. // assertChargeTypeOverrides), else the single invoice-wide default.
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }), 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, natureOfSupplies: invoice.natureOfSupplies,
unitDefault: invoice.unitDefault, unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!, incomeWithholdValue: invoice.incomeWithholdValue!,
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
buyerCountryCode: invoice.buyerCountryCode, buyerCountryCode: invoice.buyerCountryCode,
buyerRegionCodes: invoice.buyerRegionCodes, buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes, buyerWeredaCodes: invoice.buyerWeredaCodes,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,
exchangeRate: input.exchangeRate ?? null, 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 { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures"; import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors"; import { EimsApiException } from "./eims.errors";
@@ -85,6 +86,8 @@ class FakeDb {
state: EimsSystemState | null = null; state: EimsSystemState | null = null;
/** Runs before every transaction body, to simulate a concurrent writer. */ /** Runs before every transaction body, to simulate a concurrent writer. */
onTransaction: (() => void) | null = null; 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>) { constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
for (const inv of invoices) this.invoices.set(inv.id, inv); for (const inv of invoices) this.invoices.set(inv.id, inv);
@@ -133,10 +136,13 @@ class FakeDb {
return { return {
manager: this.manager, manager: this.manager,
getRepository: this.manager.getRepository, getRepository: this.manager.getRepository,
query: async (sql: string) => query: async (sql: string) => {
sql.includes("eims_system_state") if (sql.includes("eims_system_state")) {
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }] return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
: LINES, }
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
return LINES;
},
transaction: async (body: (m: unknown) => Promise<unknown>) => { transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.(); this.onTransaction?.();
return body(this.manager); return body(this.manager);
@@ -155,6 +161,7 @@ const build = (
postBearer: jest.Mock = jest.fn(), postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock | undefined = undefined, getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined), notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
) => ) =>
new EimsInvoiceRegistrationService( new EimsInvoiceRegistrationService(
db.asDataSource(), db.asDataSource(),
@@ -164,6 +171,7 @@ const build = (
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION), getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService, } as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService, { notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService,
); );
/** /**
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
}, },
}); });
const okResponse = (irn = IRN) => const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } }); statusCode: 200,
message: "SUCCESS",
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
});
const apiError = (kind: string, status?: number) => const apiError = (kind: string, status?: number) =>
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status); 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 () => { it("sends the exact reserved counter and previous IRN to the mapper", async () => {
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" }); const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
const postSigned = jest.fn().mockResolvedValue(okResponse()); const postSigned = jest.fn().mockResolvedValue(okResponse());
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1); 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 db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest const postSigned = jest
.fn() .fn()
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
); );
await service.registerInvoiceWithEims(OTHER_INVOICE_ID); await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// The two numbers move differently, because MoR constrains them differently: the counter must // A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
// not skip (it returns), the document number must not repeat (it is burned). // sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest; const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest; const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7); expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7); expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5"); 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"; } from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types"; import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; 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 { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
private readonly client: EimsClientService, private readonly client: EimsClientService,
private readonly auth: EimsAuthService, private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService, private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {} ) {}
private get cfg(): EimsConfig { private get cfg(): EimsConfig {
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
let irn: string; let irn: string;
let ackDate: string | undefined; let ackDate: string | undefined;
let signedQR: string | undefined;
try { try {
// Deliberately outside every transaction — no DB lock is held across the wire. // Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request); const result = await this.submit(request);
irn = result.irn; irn = result.irn;
ackDate = result.ackDate; ackDate = result.ackDate;
signedQR = result.signedQR;
} catch (err) { } catch (err) {
await this.settleFailure(invoiceId, reservation, err); await this.settleFailure(invoiceId, reservation, err);
throw 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( this.logger.log(
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`, `Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
); );
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
reservation: Reservation, reservation: Reservation,
irn: string, irn: string,
ackDate?: string, ackDate?: string,
signedQR?: string,
): Promise<void> { ): Promise<void> {
let companyId: string | undefined;
let invoiceNumber = invoiceId;
await this.dataSource.transaction(async (manager) => { 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, { await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered, eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn, eimsIrn: irn,
eimsAckDate: ackDate ?? null, eimsAckDate: ackDate ?? null,
eimsSignedQr: signedQR ?? null,
eimsLastError: null, eimsLastError: null,
}); });
await manager.update(EimsSystemState, reservation.stateId, { await manager.update(EimsSystemState, reservation.stateId, {
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
blockedReason: null, 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 * ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document. * 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 * - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
* document MoR definitively refused was never counted there, so ours must not advance either. * - `DocumentNumber`: "Document number error. Document number is not in correct sequence
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not * expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
* unique". It is therefore spent by the attempt itself and never handed back, even for a * `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
* refusal. * 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. * An ambiguous result keeps both: MoR may have counted and stored the document.
*/ */
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
reservation.stateId, reservation.stateId,
deterministic deterministic
? { ? {
// Counter returns (MoR never counted a refused document); the document number does // Both return: MoR never counted a refused document against either sequence.
// not (MoR requires it to be unique, so it is burned by the attempt).
nextInvoiceCounter: reservation.invoiceCounter, nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null, inFlightInvoiceId: null,
inFlightCounter: null, inFlightCounter: null,
inFlightDocumentNumber: null, inFlightDocumentNumber: null,
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
await this.alertStaff(invoiceId, status, lastError, deterministic); 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. * Tell the people who can act about a failed filing.
* *
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
// ── internals ──────────────────────────────────────────────────────────────────────────────── // ── internals ────────────────────────────────────────────────────────────────────────────────
/** A non-empty IRN is the only success signal; anything else is a failed registration. */ /** 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>( const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
"/v1/register", "/v1/register",
request, request,
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
response?.statusCode, 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> { private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
} }
private toView(invoice: Invoice): EimsInvoiceStatusView { private toView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter; return toEimsInvoiceStatusView(invoice);
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,
};
} }
} }

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 { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; 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 { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
/** /**
* Manual EIMS actions on an existing invoice. * 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 * 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. * 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 * `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
* to named admins instead. They are also separate permissions: resolving clears the system-wide * out of every role preset and assigned to named admins instead. They are also separate
* chain block and can record an IRN against an invoice, which is a supervisor action, not an * permissions: resolving clears the system-wide chain block and can record an IRN against an
* operational one. Only `eims/status` rides on the ordinary `invoices:view`. * 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: * 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. * 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() @ApiBearerAuth()
@Controller("invoices") @Controller("invoices")
export class EimsInvoiceController { 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") @Post(":id/eims/register")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
status(@Param("id", ParseUUIDPipe) id: string) { status(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.getEimsStatus(id); 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", Registered = "REGISTERED",
Failed = "FAILED", Failed = "FAILED",
Unknown = "UNKNOWN", 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. */ /** `body` of a successful `POST /v1/register`, as observed in the collection. */
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
body?: EimsVerifyResponseBody; 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. */ /** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError { export interface EimsInvoiceError {
kind: string; kind: string;
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
eimsSubmittedAt: Date | null; eimsSubmittedAt: Date | null;
eimsAckDate: string | null; eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | 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, * 1. compact `JSON.stringify` of the **inner** request object only,
* 2. those exact UTF-8 bytes, * 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), * 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 * The outer `{request, signature, certificate}` envelope is never itself signed, and the request
* object is never mutated after serialization. * object is never mutated after serialization.

View File

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

View File

@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity"; import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { EimsAuthService } from "./eims-auth.service"; import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service"; import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsClientService } from "./eims-client.service"; import { EimsClientService } from "./eims-client.service";
import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceController } from "./eims-invoice.controller";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
import { EimsSignerService } from "./eims-signer.service"; import { EimsSignerService } from "./eims-signer.service";
import { EimsReceipt } from "./entities/eims-receipt.entity";
import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity";
/** /**
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
@Module({ @Module({
imports: [ imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }), HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]), TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
NotificationInboxModule, NotificationInboxModule,
NotificationsModule,
], ],
controllers: [EimsInvoiceController], controllers: [EimsInvoiceController],
providers: [ providers: [
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
EimsClientService, EimsClientService,
EimsInvoiceRegistrationService, EimsInvoiceRegistrationService,
EimsAutoSubmitService, EimsAutoSubmitService,
EimsCancellationService,
EimsReceiptService,
],
exports: [
EimsAuthService,
EimsClientService,
EimsInvoiceRegistrationService,
EimsCancellationService,
EimsReceiptService,
], ],
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
}) })
export class EimsModule {} 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 }) @Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null; 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; 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 { export class NotificationsGateway implements OnGatewayConnection {
private readonly logger = new Logger(NotificationsGateway.name); 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() @WebSocketServer()
private readonly server!: Server; private readonly server?: Server;
constructor(private readonly wsAuth: WsAuthService) {} 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. */ /** Push a freshly-created notification + the new unread count to a user. */
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
if (!this.server) return this.skip("emitNew");
const room = this.server.to(this.room(userId)); const room = this.server.to(this.room(userId));
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); 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). */ /** Push only an updated unread count (e.g. after a read on another tab). */
emitUnreadCount(userId: string, unreadCount: number): void { emitUnreadCount(userId: string, unreadCount: number): void {
if (!this.server) return this.skip("emitUnreadCount");
this.server this.server
.to(this.room(userId)) .to(this.room(userId))
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); .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 { private room(userId: string): string {
return `user:${userId}`; return `user:${userId}`;
} }

View File

@@ -1,6 +1,7 @@
// otp.service.ts // otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { randomInt } from "node:crypto"; import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository"; import { OtpRepository } from "./otp.repository";
@@ -317,6 +318,13 @@ export class OtpService {
}`; }`;
if (result === "ok") this.logger.log(line); if (result === "ok") this.logger.log(line);
else this.logger.warn(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 { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { logCtx } from "@edr/api-common";
import { import {
InitiatePaymentRequest, InitiatePaymentRequest,
PaymentIntentSnapshot, PaymentIntentSnapshot,
@@ -111,6 +112,16 @@ export class PaymentClientService {
body?: unknown, body?: unknown,
): Promise<T> { ): Promise<T> {
const url = `${this.baseUrl}${path}`; 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 { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.request<T>({ this.http.request<T>({
@@ -122,9 +133,11 @@ export class PaymentClientService {
: {}, : {},
}), }),
); );
trace({ status: response.status });
return response.data; return response.data;
} catch (err) { } catch (err) {
if (err instanceof AxiosError && err.response) { if (err instanceof AxiosError && err.response) {
trace({ status: err.response.status });
if (err.response.status === 404) throw err; if (err.response.status === 404) throw err;
const detail = const detail =
(err.response.data as { message?: string | string[] })?.message ?? (err.response.data as { message?: string | string[] })?.message ??
@@ -136,6 +149,7 @@ export class PaymentClientService {
} }
const message = const message =
err instanceof Error && err.message ? err.message : String(err); err instanceof Error && err.message ? err.message : String(err);
trace({ unreachable: true, error: message });
this.logger.error( this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`, `payment service unreachable (${method} ${path}): ${message}`,
); );

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import {
RATE_UNITS, RATE_UNITS,
} from '../entities/rate.entity'; } 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. // ETB is accepted only for last-mile rates; the service forces USD elsewhere.
const CURRENCIES = ['USD', 'ETB'] as const; const CURRENCIES = ['USD', 'ETB'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
@@ -94,6 +95,17 @@ export class CreateRateDto {
@IsIn([...RATE_UNITS]) @IsIn([...RATE_UNITS])
rateUnit?: string; 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({ @ApiPropertyOptional({
description: description:
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).', '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 }) @Column({ name: 'has_lashing', type: 'boolean', default: false })
hasLashing!: boolean; 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. * Whether staff may write bulk contract templates against this cargo type.
* Mutually exclusive between a parent group and its children: if the parent * 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'; return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE': case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE'; return 'CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
} }
} }

View File

@@ -74,6 +74,9 @@ function unitsForShape(input: {
case 'LASHING': case 'LASHING':
// Bulk-only cargo securing — per ton or per wagon. // Bulk-only cargo securing — per ton or per wagon.
return ['PER_TON', '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': case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT']; return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE': case 'SHIPPING_LINE':

View File

@@ -24,6 +24,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE', 'RETURN_SURCHARGE',
'PIL_EXTRA_FEE', 'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const; ] as const;
export type RateType = typeof RATE_TYPES[number]; export type RateType = typeof RATE_TYPES[number];
@@ -41,6 +42,8 @@ export const RATE_UNITS = [
'PER_KM', 'PER_KM',
// Last-mile bulk: price = tons × km × rateValue. // Last-mile bulk: price = tons × km × rateValue.
'PER_TON_KM', 'PER_TON_KM',
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
'PER_LITER',
'PER_INVOICE', 'PER_INVOICE',
'FLAT', 'FLAT',
] as const; ] as const;
@@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice, // Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false). // never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE', '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; ] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number]; 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 = * containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
* open-ended). NULL on every other rate shape. * 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 }) @Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
minKm?: number | null; minKm?: number | null;

View File

@@ -422,3 +422,108 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)',
expect(lashingMods(result)).toHaveLength(0); 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 // matchesTrigger can fire the LASHING rate. Falls back to an explicit
// input flag when no cargo type is set (e.g. container bookings). // input flag when no cargo type is set (e.g. container bookings).
let hasLashing = input.hasLashing === true; 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) { if (input.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) { if (!cargoType) {
@@ -186,6 +189,9 @@ export class RuleEngineService {
if (cargoType.hasLashing) { if (cargoType.hasLashing) {
hasLashing = true; 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 // Lashing is sold per cargo kind + container type — billed by the
// kind-aware block below, never by this generic loop. // kind-aware block below, never by this generic loop.
if (rate.trigger === 'LASHING') continue; 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, { const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous, isHazardous: input.isHazardous,
hasReefer, hasReefer,
@@ -442,6 +451,10 @@ export class RuleEngineService {
appliedModifiers.push(...this.lashingCharges(input, liveRates)); appliedModifiers.push(...this.lashingCharges(input, liveRates));
} }
if (hasFuel) {
appliedModifiers.push(...this.fuelCharges(input, liveRates));
}
return { return {
priorityScore, priorityScore,
appliedModifiers, appliedModifiers,
@@ -632,6 +645,56 @@ export class RuleEngineService {
return modifiers; 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 * Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking * 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' }); 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 () => { it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build(); const { service } = build();
await expect( await expect(

View File

@@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [
// LIVE last-mile rate would diff to "nothing changed". // LIVE last-mile rate would diff to "nothing changed".
'minKm', 'minKm',
'maxKm', '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; ] as const;
/** /**

View File

@@ -121,15 +121,16 @@ export class RatesService {
} }
/** /**
* Rates sold per direction + route. Base freight always; customs clearance * Rates sold per direction + route. Base freight always; customs clearance,
* and empty-container return are the surcharges that are too — their fee * empty-container return and fuel are the surcharges that are too — their
* depends on the lane (and, for returns, the container type). * fee depends on the lane (and, for returns, the container type).
*/ */
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return ( return (
this.isBaseFreight(appliesTo, trigger) || this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
); );
} }
@@ -160,7 +161,9 @@ export class RatesService {
appliesTo: Rate['appliesTo'], appliesTo: Rate['appliesTo'],
tradeDirection: string | null, tradeDirection: string | null,
): { origin: YardCountry; destination: YardCountry } { ): { 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 { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
} }
return tradeDirection === 'EXPORT' return tradeDirection === 'EXPORT'
@@ -291,6 +294,31 @@ export class RatesService {
} }
return; 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') { if (trigger === 'WITH_RETURN') {
// Returning the empty box only exists on imports (the box goes back to // Returning the empty box only exists on imports (the box goes back to
// the port) — export return rates are rejected until the business sells // the port) — export return rates are rejected until the business sells
@@ -502,15 +530,21 @@ export class RatesService {
: (dto.containerTypeId ?? null); : (dto.containerTypeId ?? null);
const cargoTypeId = const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING' trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.cargoTypeId ?? null) ? (dto.cargoTypeId ?? null)
: isSurcharge : isSurcharge
? null ? null
: (dto.cargoTypeId ?? null); : (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store — // 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 = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.tradeDirection ?? null) ? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY' : isSurcharge || appliesTo === 'INTERCITY'
? null ? null
@@ -563,6 +597,8 @@ export class RatesService {
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
} }
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
await this.assertNoDuplicatePattern({ await this.assertNoDuplicatePattern({
rateType, rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
@@ -588,6 +624,7 @@ export class RatesService {
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD', currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
rateValue: dto.rateValue, rateValue: dto.rateValue,
rateUnit, rateUnit,
baseLiters,
minKm, minKm,
maxKm, maxKm,
status: 'DRAFT', 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 * 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`. * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
@@ -680,14 +736,18 @@ export class RatesService {
const keepsCargoType = const keepsCargoType =
!isSurcharge || !isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING'; trigger === 'LASHING' ||
trigger === 'FUEL';
const cargoTypeId = !keepsCargoType const cargoTypeId = !keepsCargoType
? null ? null
: dto.cargoTypeId !== undefined : dto.cargoTypeId !== undefined
? dto.cargoTypeId ? dto.cargoTypeId
: existing.cargoTypeId; : existing.cargoTypeId;
const tradeDirection = 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 !== undefined
? dto.tradeDirection ? dto.tradeDirection
: existing.tradeDirection : existing.tradeDirection
@@ -788,6 +848,11 @@ export class RatesService {
ignoreId: id, ignoreId: id,
}); });
updates.baseLiters = this.resolveBaseLiters(
rateUnit,
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
);
updates.currency = updates.currency =
appliesTo === 'LAST_MILE' appliesTo === 'LAST_MILE'
? (dto.currency ?? existing.currency ?? 'ETB') ? (dto.currency ?? existing.currency ?? 'ETB')

View File

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

View File

@@ -129,6 +129,7 @@ import {
perEdgeConsistUsage, perEdgeConsistUsage,
validateContainerPlacements, validateContainerPlacements,
validateMixedTrainLimitsPerEdge, validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput, type ContainerPlacementInput,
type WagonPlanSlot, type WagonPlanSlot,
} from '../utils/wagon-plan.util'; } from '../utils/wagon-plan.util';
@@ -330,6 +331,8 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
interface BookingWindowRow { interface BookingWindowRow {
schedule_id: string; schedule_id: string;
reference: string | null; reference: string | null;
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
train_number: string | null;
contract_id: string | null; contract_id: string | null;
contract_kind: string | null; contract_kind: string | null;
direction: string | null; direction: string | null;
@@ -2928,6 +2931,10 @@ export class TrainSchedulingService {
// dispatch pre-check keeps reporting these bookings as unloaded). // dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) { 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 // 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. // be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) { 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 { private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) => const esc = (value: unknown) =>
String(value ?? '-') String(value ?? '-')
@@ -5872,6 +5945,7 @@ export class TrainSchedulingService {
wagonSlots: schedule.trainSet?.wagons, wagonSlots: schedule.trainSet?.wagons,
storedWagonCount: schedule.trainSet?.wagonCount, storedWagonCount: schedule.trainSet?.wagonCount,
scheduleBookings: schedule.scheduleBookings, scheduleBookings: schedule.scheduleBookings,
maxWagons: schedule.maxWagons,
}); });
return { return {
@@ -6847,6 +6921,7 @@ export class TrainSchedulingService {
`SELECT DISTINCT ON (ts.id) `SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id, ts.id AS schedule_id,
ts.reference AS reference, ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id, cr.contract_id AS contract_id,
c.contract_kind AS contract_kind, c.contract_kind AS contract_kind,
ts.direction, ts.direction,
@@ -6903,6 +6978,7 @@ export class TrainSchedulingService {
const rows: Array<BookingWindowRow> = await this.dataSource.query( const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id, `SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference, ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id, cr.contract_id AS contract_id,
c.contract_kind AS contract_kind, c.contract_kind AS contract_kind,
ts.direction, ts.direction,
@@ -6948,9 +7024,7 @@ export class TrainSchedulingService {
*/ */
async listAllBookingWindows() { async listAllBookingWindows() {
const rows: Array< const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & { Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
train_number: string | null;
}
> = await this.dataSource.query( > = await this.dataSource.query(
`SELECT ts.id AS schedule_id, `SELECT ts.id AS schedule_id,
ts.reference AS reference, ts.reference AS reference,
@@ -6980,14 +7054,13 @@ export class TrainSchedulingService {
AND ts.scheduled_departure_date >= now() AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
); );
return rows.map((r) => ({ return rows.map((r) =>
...this.mapBookingWindowRow({ this.mapBookingWindowRow({
...r, ...r,
contract_id: null, contract_id: null,
contract_kind: null, contract_kind: null,
}), }),
trainNumber: r.train_number, );
}));
} }
private mapBookingWindowRow(r: BookingWindowRow) { private mapBookingWindowRow(r: BookingWindowRow) {
@@ -7005,6 +7078,7 @@ export class TrainSchedulingService {
return { return {
scheduleId: r.schedule_id, scheduleId: r.schedule_id,
reference: r.reference ?? null, reference: r.reference ?? null,
trainNumber: r.train_number ?? null,
contractId: r.contract_id, contractId: r.contract_id,
contractKind: r.contract_kind, contractKind: r.contract_kind,
direction: r.direction, direction: r.direction,
@@ -9301,9 +9375,14 @@ export class TrainSchedulingService {
} }
// Every schedule the target train is committed to, via its train sets. // 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 const targetSets = await this.dataSource
.getRepository(TrainSet) .getRepository(TrainSet)
.find({ where: { trainId: targetTrainId } }); .find({
where: { trainId: targetTrainId },
relations: { locomotives: { locomotive: true }, locomotive: true },
});
const targetSetIds = targetSets.map((s) => s.id); const targetSetIds = targetSets.map((s) => s.id);
const targetSchedules = targetSetIds.length const targetSchedules = targetSetIds.length
? await this.dataSource.getRepository(TrainSchedule).find({ ? await this.dataSource.getRepository(TrainSchedule).find({
@@ -9346,6 +9425,24 @@ export class TrainSchedulingService {
.getRepository(Wagon) .getRepository(Wagon)
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } }); .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 const movingBookings = absorbed
? await this.dataSource.getRepository(TrainScheduleBooking).find({ ? await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: absorbed.id }, where: { trainScheduleId: absorbed.id },
@@ -9357,10 +9454,12 @@ export class TrainSchedulingService {
schedule, schedule,
sourceTrainId, sourceTrainId,
targetTrain, targetTrain,
targetSets,
absorbed, absorbed,
affectedOthers, affectedOthers,
untouched, untouched,
incomingWagons, incomingWagons,
looseSourceWagons,
movingBookings, 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) => ({ const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters) || 0, lengthMeters: Number(w.lengthMeters) || 0,
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0, tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
cargoTons: 0, cargoTons: 0,
})); }));
const wagonTypeIds = [ 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 const wagonTypes = wagonTypeIds.length
? await this.dataSource ? await this.dataSource
@@ -9397,16 +9502,27 @@ export class TrainSchedulingService {
.find({ where: { id: In(wagonTypeIds) } }) .find({ where: { id: In(wagonTypeIds) } })
: []; : [];
const typeById = new Map(wagonTypes.map((t) => [t.id, t])); 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); const t = typeById.get(w.wagonTypeId);
return { return {
lengthMeters: Number(t?.lengthMeters) || 0, lengthMeters: Number(t?.lengthMeters) || 0,
tareWeightTons: Number(t?.tareWeightTons) || 0, tareWeightTons: Number(t?.tareWeightTons) || 0,
cargoTons: 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) { if (limits) {
const rules = await this.dataSource const rules = await this.dataSource
.getRepository(TrainSchedulingGlobalRules) .getRepository(TrainSchedulingGlobalRules)
@@ -9415,13 +9531,17 @@ export class TrainSchedulingService {
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined, maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined, maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
}); });
const merged = [...existingSlots, ...incomingSlots]; const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
// maxWagons is the schedule's own slot ceiling; fall back to the consist // Merge is a physical consist move, so only the physical axes gate it:
// size when it is unset so the count axis never blocks spuriously. // 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, { const violations = consistViolations(merged, {
maxWeightTons: caps.maxWeightTons, maxWeightTons: caps.maxWeightTons,
maxLengthMeters: caps.maxLengthMeters, maxLengthMeters: caps.maxLengthMeters,
maxWagonSlots: schedule.maxWagons || merged.length, maxWagonSlots: merged.length,
}); });
blockers.push(...violations); blockers.push(...violations);
} }
@@ -9481,7 +9601,10 @@ export class TrainSchedulingService {
const plan = await this.planMerge(scheduleId, targetTrainId); const plan = await this.planMerge(scheduleId, targetTrainId);
const blockers = await this.mergeBlockers(plan); 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 { return {
canMerge: blockers.length === 0, canMerge: blockers.length === 0,
blockers, blockers,
@@ -9555,13 +9678,20 @@ export class TrainSchedulingService {
trainId: targetTrain.id, 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) { if (incomingWagons.length) {
await manager.getRepository(Wagon).update( await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) }, { id: In(incomingWagons.map((w) => w.id)) },
{ trainId: targetTrain.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 // 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder // after the existing wagons. Sequence is provisional — staff reorder
@@ -9611,6 +9741,14 @@ export class TrainSchedulingService {
await manager await manager
.getRepository(TrainSet) .getRepository(TrainSet)
.update(trainSetId, { wagonCount: mergedCount }); .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( this.logger.log(

View File

@@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => {
expect(usage.wagonsRemaining).toBe(0); 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', () => { it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({ const usage = computeScheduleWagonUsage({
wagonSlots: [], wagonSlots: [],

View File

@@ -20,11 +20,11 @@ export interface ScheduleBookingLike {
export interface ScheduleWagonUsage { export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */ /** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number; wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */ /** Schedule capacity — the larger of coupled consist and planned ceiling. */
wagonsTotal: number; wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */ /** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number; wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */ /** Capacity minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number; wagonsRemaining: number;
} }
@@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: {
/** Stored counter; used only when the slot rows were not loaded. */ /** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null; storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null; scheduleBookings?: ScheduleBookingLike[] | null;
/** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */
maxWagons?: number | null;
}): ScheduleWagonUsage { }): ScheduleWagonUsage {
const slots = input.wagonSlots ?? []; 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 // 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. // 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. // An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce( const wagonsReserved = (input.scheduleBookings ?? []).reduce(

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm"; 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 { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; 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. * actually produce is a backoffice decision, edited in the file-settings editor.
*/ */
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ 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", fileKey: "cooperative_registration_certificate",
fileLabel: "TIN Certificate", fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText: "Verified against the TIN registry during registration.", helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
isRequired: true, isRequired: true,
isMultiple: false, isMultiple: false,
maxFiles: 1, maxFiles: 1,
@@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
displayOrder: 1, displayOrder: 1,
}, },
{ {
fileKey: "cooperative_registration_certificate", fileKey: "tin_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate", fileLabel: "TIN Certificate",
helpText: helpText: "Verified against the TIN registry during registration.",
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
isRequired: true, isRequired: true,
isMultiple: false, isMultiple: false,
maxFiles: 1, maxFiles: 1,
@@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder {
async run() { async run() {
const settingRepository = this.dataSource.getRepository(FileUploadSetting); const settingRepository = this.dataSource.getRepository(FileUploadSetting);
// Seed only into an empty table: any existing rows (including // Seed per CODE, not "only into an empty table". An existing row is
// soft-deleted ones, which would still conflict on the unique `code`) // admin-managed and never touched — including a soft-deleted one, which
// mean the data is admin-managed, so leave it untouched. // means the set was removed on purpose (and would still conflict on the
const existing = await settingRepository.count({ withDeleted: true }); // unique `code`). What the table-wide check got wrong is the other half: a
if (existing > 0) { // set added to this file after the first boot could never reach a database
this.logger.log( // that already held the others, so it existed in code and nowhere else.
`file_upload_settings already has ${existing} rows — skipping seed`, const existingCodes = new Set(
); (await settingRepository.find({ withDeleted: true })).map((s) => s.code),
return; );
}
const allSettings: Array< const allSettings: Array<
OnboardingDocumentSetting & { description: string } OnboardingDocumentSetting & { description: string }
@@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder {
})), })),
]; ];
// Insert setting rows only — no FileUploadField rows. Fields start empty const missing = allSettings.filter((s) => !existingCodes.has(s.code));
// and are configured from the backoffice file-settings editor; the field if (missing.length === 0) {
// definitions above are kept as reference defaults. this.logger.log("file upload settings up to date — nothing to seed");
await settingRepository.insert( return;
allSettings.map((documentSetting) => ({ }
code: documentSetting.code,
label: documentSetting.label, const inserted = await settingRepository.save(
description: documentSetting.description, missing.map((documentSetting) =>
entity: documentSetting.entity, 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( 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", "edr_freight_app:invoices:eims_resolve",
"Resolve a blocked MoR EIMS submission", "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 // 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. // the invoice. Moves money state, so it is its own grant, not part of view.
perm( perm(
@@ -1190,22 +1204,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm( perm(
"b4b00002-0001-4000-8000-000000000001", "b4b00002-0001-4000-8000-000000000001",
"edr_freight_app:settings:stamp:view", "edr_freight_app:settings:stamp:view",
"View stamp settings", "View the company stamp",
), ),
perm( perm(
"b4b00002-0001-4000-8000-000000000002", "b4b00002-0001-4000-8000-000000000002",
"edr_freight_app:settings:stamp:manage", "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( perm(
"b4b00003-0001-4000-8000-000000000001", "b4b00003-0001-4000-8000-000000000001",
"edr_freight_app:settings:invoice_stamp:view", "edr_freight_app:settings:teeter:view",
"View invoice stamp settings", "View own approval teeter and signature",
), ),
perm( perm(
"b4b00003-0001-4000-8000-000000000002", "b4b00003-0001-4000-8000-000000000002",
"edr_freight_app:settings:invoice_stamp:manage", "edr_freight_app:settings:teeter:manage",
"Manage invoice stamp settings", "Manage own approval teeter and signature",
), ),
perm( perm(
"b4c00001-0001-4000-8000-000000000001", "b4c00001-0001-4000-8000-000000000001",
@@ -1701,6 +1721,8 @@ export const FREIGHT_PERMS = {
export: "edr_freight_app:invoices:export", export: "edr_freight_app:invoices:export",
eimsRegister: "edr_freight_app:invoices:eims_register", eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve", 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", confirmOffline: "edr_freight_app:invoices:confirm_offline",
}, },
firstMile: { firstMile: {
@@ -1907,15 +1929,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", 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: { stamp: {
view: "edr_freight_app:settings:stamp:view", view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage", manage: "edr_freight_app:settings:stamp:manage",
}, },
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate // The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// from `stamp` above, which is the per-employee approval-record teeter. // and NOT the company seal above. Retired: `invoiceStamp`, which used to
invoiceStamp: { // gate the company stamp before the two were untangled.
view: "edr_freight_app:settings:invoice_stamp:view", teeter: {
manage: "edr_freight_app:settings:invoice_stamp:manage", view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
}, },
exchangeRate: { exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
@@ -2216,10 +2241,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.export,
// Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve. // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
// Invoices are filed with MoR by the workflow, not by a person, so filing is not a // eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
// Finance job function — the endpoints exist for controlled testing and exceptional // filing is not a Finance job function — the endpoints exist for controlled testing and
// operations, and are assigned to named admins rather than a role preset. // exceptional operations, and are assigned to named admins rather than a role preset.
FREIGHT_PERMS.payments.view, FREIGHT_PERMS.payments.view,
FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationView,
], ],

View File

@@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage"; import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature"; import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -786,23 +785,27 @@ const App = () => {
</RequirePermission> </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 <Route
path="stamp-settings" path="stamp-settings"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}> <RequirePermission
<StampSettings /> permission={FREIGHT_PERMS.settings.stamp.view}
>
<CompanyStampSettingsPage />
</RequirePermission> </RequirePermission>
} }
/> />
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
<Route <Route
path="invoice-stamp-settings" path="invoice-stamp-settings"
element={ element={<Navigate to="/dashboard/stamp-settings" replace />}
<RequirePermission
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
>
<InvoiceStampSettingsPage />
</RequirePermission>
}
/> />
<Route <Route
path="contract-templates" 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, type ResetPasswordActionProps,
} from "./ResetPasswordAction"; } from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export {
PersonCard,
type PersonCardProps,
type PersonField,
} from "./PersonCard";
export { TableCard, type TableCardProps } from "./TableCard"; export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -19,6 +19,7 @@ import {
PackageOpen, PackageOpen,
Paperclip, Paperclip,
Receipt, Receipt,
Stamp,
ScrollText, ScrollText,
Send, Send,
Settings, Settings,
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
permission: FREIGHT_PERMS.settings.dropdown.view, 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", href: "/dashboard/stamp-settings",
icon: <FileSignature />, icon: <Stamp />,
permission: FREIGHT_PERMS.settings.stamp.view, 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", label: "Contract templates",
href: "/dashboard/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")} {t("header.viewProfile")}
</DropdownMenuItem> </DropdownMenuItem>
<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"> 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" /> <Key className="w-4 h-4 mr-3" />
{t("header.changePassword")} {t("header.changePassword")}

View File

@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", 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: { stamp: {
view: "edr_freight_app:settings:stamp:view", view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage", manage: "edr_freight_app:settings:stamp:manage",
}, },
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate // The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// from `stamp` above, which is the per-employee approval-record teeter. // and NOT the company seal above. Retired: `invoiceStamp`, which used to
invoiceStamp: { // gate the company stamp before the two were untangled.
view: "edr_freight_app:settings:invoice_stamp:view", teeter: {
manage: "edr_freight_app:settings:invoice_stamp:manage", view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
}, },
exchangeRate: { exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",

View File

@@ -502,7 +502,7 @@ const Header = () => {
<DropdownMenuItem <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" 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" /> <Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium"> <span className="font-medium">

View File

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

View File

@@ -8,7 +8,7 @@ import {
Card, Card,
Center, Center,
Container, Container,
Divider, Grid,
Group, Group,
Loader, Loader,
SimpleGrid, SimpleGrid,
@@ -21,6 +21,7 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Banknote, Banknote,
Contact,
Download, Download,
Eye, Eye,
FileText, FileText,
@@ -32,6 +33,8 @@ import {
FilePen, FilePen,
Paperclip, Paperclip,
Receipt, Receipt,
UserCheck,
UserRound,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -46,6 +49,7 @@ import {
CompanyTypeBadge, CompanyTypeBadge,
InvoiceStatusBadge, InvoiceStatusBadge,
PaymentStatusBadge, PaymentStatusBadge,
PersonCard,
ProfileApprovalActions, ProfileApprovalActions,
ProfileChips, ProfileChips,
ProfileStatusBadge, ProfileStatusBadge,
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label={`View ${f.name}`} 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} /> <Eye size={14} />
</ActionIcon> </ActionIcon>
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
type="button" type="button"
size="xs" size="xs"
lineClamp={1} lineClamp={1}
onClick={() => void fetchViewableFile(f.id, f.name).then(view)} onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
style={{ style={{
maxWidth: 170, maxWidth: 170,
textAlign: "left", 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, (p) => p.licenseFiles && p.licenseFiles.length > 0,
); );
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
[documents], [documents],
); );
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE); const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
const poaFields = [ const hasPoaDetails = [
{ label: "PoA name", value: company?.poaName }, company?.poaName,
{ label: "PoA email", value: company?.poaEmail }, company?.poaEmail,
{ label: "PoA phone", value: company?.poaPhone }, company?.poaPhone,
{ label: "PoA location", value: company?.poaLocation }, company?.poaLocation,
{ label: "PoA address", value: company?.poaAddress }, company?.poaAddress,
]; ].some((v) => v?.trim());
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so // Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces. // this page can never disagree with the rule the API actually enforces.
const identityState = company?.identity; const identityState = company?.identity;
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details // A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional. // and DARS delegation paper both — is mandatory rather than optional.
const poaMandatory = (company?.companyProfiles ?? []).some( const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
(p) => p.type === "freight_forwarder",
);
const delegationMissing = const delegationMissing =
company?.identity?.poaDeclared === "yes" && poaLive.length === 0; company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
]} ]}
backTo="/dashboard/customers" backTo="/dashboard/customers"
title={company.name} title={company.name}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" subtitle={`TIN ${company.tin}${
}`} company.country ? ` · ${company.country}` : ""
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
items={[ items={[
{ {
label: "Profiles", label: "Profiles",
value: company.companyProfiles.length, value: profiles.length,
icon: IdCard, icon: IdCard,
color: "edr-green", color: "edr-green",
}, },
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
: "Pending approval", : "Pending approval",
value: stillOnboarding value: stillOnboarding
? "—" ? "—"
: company.companyProfiles.filter( : profiles.filter((p) => p.status === "pending").length,
(p) => p.status === "pending",
).length,
icon: IdCard, icon: IdCard,
color: "yellow", color: "yellow",
}, },
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
]} ]}
/> />
<Card> <Grid gap="lg" align="flex-start">
<Stack gap="lg"> {/* Company facts — the wide column. People live in the narrow one
<Text fw={600} c="edr-text"> beside it, so nothing about a person is stated twice. */}
Company information <Grid.Col span={{ base: 12, lg: 8 }}>
</Text> <Stack gap="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <Card>
<InfoField label="TIN" value={company.tin} /> <Stack gap="lg">
<InfoField label="VAT number" value={company.vatNumber} /> <Text fw={600} c="edr-text">
<InfoField label="FAN number" value={company.fanNumber} /> Company information
<InfoField </Text>
label="Submitted on" <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
value={formatDate(company.createdAt)} <InfoField label="TIN" value={company.tin} />
/>
<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 && (
<>
<InfoField <InfoField
label="PoA verified at" label="VAT number"
value={formatDate(poaIdentity.verifiedAt)} 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 <InfoField
label="PoA birthdate" label="Approved on"
value={poaIdentity.birthdate} value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/> />
<InfoField label="PoA gender" value={poaIdentity.gender} /> </SimpleGrid>
</> </Stack>
)} </Card>
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No Power of Attorney representative recorded for this
customer.
</Text>
)}
<Divider /> <Card>
<Stack gap="lg">
<Stack gap="sm"> <Group gap="xs" wrap="nowrap" justify="space-between">
<Text <Group gap="xs" wrap="nowrap">
size="xs" <Text fw={600} c="edr-text">
fw={600} eTrade registration
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)}
</Text> </Text>
{doc.code === POA_DELEGATION_PENDING_CODE && ( {hasEtradeRecord ? (
<Badge <Badge size="sm" color="edr-green" variant="light">
size="xs" Verified with eTrade
color="yellow" </Badge>
variant="light" ) : (
className="shrink-0" <Badge size="sm" color="gray" variant="light">
> No eTrade record
Pending approval
</Badge> </Badge>
)} )}
</Group> </Group>
<Group gap={4} wrap="nowrap"> {hasEtradeRecord && (
<ActionIcon <ActionIcon
variant="subtle" variant="default"
color="gray" aria-label="Download TIN record"
aria-label={`Preview ${doc.name}`} onClick={() => downloadTinRecord(company)}
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}`}
> >
<Download size={16} /> <Download size={16} />
</ActionIcon> </ActionIcon>
</Group> )}
</Group> </Group>
)) {hasEtradeRecord ? (
)} <SimpleGrid
</Stack> cols={{ base: 1, sm: 2, lg: 3 }}
</Stack> spacing="lg"
</Card> >
<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> <Card>
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between"> <Group justify="space-between">
<Text fw={600} c="edr-text"> <Text fw={600} c="edr-text">
Role profiles Role profiles
</Text> </Text>
<ProfileChips profiles={company.companyProfiles} /> <ProfileChips profiles={profiles} />
</Group> </Group>
<Box style={{ overflowX: "auto" }} w="100%"> {/* Narrower than the old full-width layout — the table
<Box miw={1040}> shares the row with the people column now. */}
<DataTable <Box style={{ overflowX: "auto" }} w="100%">
columns={profileColumns} <Box miw={760}>
data={company.companyProfiles} <DataTable
status="success" columns={profileColumns}
emptyMessage="No profiles registered." data={profiles}
containerClassName="border-0 shadow-none bg-transparent" status="success"
/> emptyMessage="No profiles registered."
</Box> containerClassName="border-0 shadow-none bg-transparent"
</Box> />
</Stack> </Box>
</Card> </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> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
message: "Failed to load bookings.", message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(), onRetry: () => void bookingsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
error={ error={
documentsQuery.isError documentsQuery.isError
? { ? {
message: "Failed to load documents.", message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(), onRetry: () => void documentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
error={ error={
paymentsQuery.isError paymentsQuery.isError
? { ? {
message: "Failed to load payments.", message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(), onRetry: () => void paymentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
error={ error={
invoicesQuery.isError invoicesQuery.isError
? { ? {
message: "Failed to load invoices.", message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(), onRetry: () => void invoicesQuery.refetch(),
} }
: undefined : undefined
} }
pagination={{ pagination={{

View File

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

View File

@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean; requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */ /** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean; 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). */ /** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean; hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */ /** 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 // When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing). // surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { 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 // 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 — // rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level. // the template must live on exactly one level.

View File

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

View File

@@ -181,6 +181,17 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" }, { label: "Cancellation", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, { 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>) => const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) || isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" && (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 }); const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -255,6 +266,9 @@ const unitsForShape = (
case "LASHING": case "LASHING":
// Bulk-only cargo securing — per ton or per wagon. // Bulk-only cargo securing — per ton or per wagon.
return ["PER_TON", "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 "CONSOLIDATION":
case "SHIPPING_LINE": case "SHIPPING_LINE":
case "PIL_EXTRA_FEE": case "PIL_EXTRA_FEE":
@@ -371,6 +385,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
}, },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", 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" }, { name: "isActive", label: "Active", type: "boolean" },
], ],
}, },
@@ -834,7 +855,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
filters: { filters: {
appliesTo: "OTHER", appliesTo: "OTHER",
trigger: 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", type: "select",
required: true, required: true,
optionsFromValues: (v: Record<string, unknown>) => 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 === "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) => showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || ["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" && (String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes( ["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""), String(v.trigger ?? ""),
)), )),
}, },
@@ -939,6 +963,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
v.trigger === "CUSTOMS_CLEARANCE" && v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK", 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 // ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ────────── // 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.trigger ?? "") !== "OVERWEIGHT" &&
String(v.appliesTo ?? "") !== "LAST_MILE", 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"; } from "@/hooks/useStampSettings";
/** /**
* The one company stamp/seal stamped onto every generated invoice/receipt * The ONE company stamp/seal, read by every document path server-side via
* PDF (InvoiceDocumentService). Single global image no per-employee choice. * 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 { data, isLoading } = useStampSettingsQuery();
const setStamp = useSetStamp(); const setStamp = useSetStamp();
const clearStamp = useClearStamp(); const clearStamp = useClearStamp();
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
<div className="p-4 w-full max-w-screen-sm mx-auto"> <div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader> <CardHeader>
<CardTitle>Invoice stamp</CardTitle> <CardTitle>Company stamp</CardTitle>
<CardDescription> <CardDescription>
Stamped onto every generated invoice and receipt PDF. Replacing it The single EDR seal, applied to every generated document invoices
here changes it everywhere at once there is no per-invoice or and receipts, warehouse release and handover papers, and the EDR
per-user choice. 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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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 * 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 * carrying a booking allocation, the denominator is the schedule's capacity
* excludes wagons reserved by bookings that have not paid yet — that space is * (API-computed: the larger of coupled consist and planned `maxWagons`, since
* claimed, so it is not bookable. * 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
* Schedules whose train set has not been built yet have no consist to measure, * bookable.
* 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.
*/ */
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) { function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still // 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 total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed; const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0; const reserved = schedule.wagonsReserved ?? 0;
const remaining = schedule.wagonsRemaining;
// 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;
if (used == null) { if (used == null) {
return <MetricChip value={total} label="wgn" subtle />; return <MetricChip value={total} label="wgn" subtle />;
@@ -1052,11 +1039,9 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
return ( return (
<> <>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip <MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`} value={`${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"} label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/> />
{reserved > used ? ( {reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle /> <MetricChip value={reserved} label="reserved" subtle />

View File

@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
<DropdownMenuItem <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" 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" /> <Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.changePassword")}</span> <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, WarehouseZone,
} from "@/types/warehouse"; } from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint"; import { endpoint } from "@/utils/endpoint";
import {
accountService,
type ChangePasswordPayload,
type SendContactOtpPayload,
type UpdateContactPayload,
} from "./account.service";
import { import {
BookingListFilter, BookingListFilter,
bookingsService, 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: { signatures: {
mySignature: endpoint<void, SavedSignature | null>( mySignature: endpoint<void, SavedSignature | null>(
"me", "me",

View File

@@ -2,6 +2,10 @@ import { api as client } from "../auth/http";
const BASE = "/contract-templates"; 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 { export interface ContractTemplateArticle {
id: string; id: string;
title: string; title: string;
@@ -23,7 +27,12 @@ export interface ContractTemplate {
/** Bulk templates only: the cargo type this template is written for. */ /** Bulk templates only: the cargo type this template is written for. */
cargoTypeId?: string | null; cargoTypeId?: string | null;
cargoType?: { id: string; cargoTypeName: 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; withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */ /** The five seeded container templates — cannot be deleted. */
isSystem: boolean; isSystem: boolean;
@@ -33,7 +42,9 @@ export interface ContractTemplate {
export interface CreateContractTemplatePayload { export interface CreateContractTemplatePayload {
cargoTypeId: string; cargoTypeId: string;
withCustoms: boolean; tradeDirection: BulkTemplateDirection;
/** Omitted for INTERCITY — the API rejects the flag there. */
withCustoms?: boolean;
name?: string; name?: string;
description?: string; description?: string;
} }

View File

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

View File

@@ -9,7 +9,8 @@ export type EimsInvoiceStatus =
| "SUBMITTING" | "SUBMITTING"
| "REGISTERED" | "REGISTERED"
| "FAILED" | "FAILED"
| "UNKNOWN"; | "UNKNOWN"
| "CANCELLED";
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */ /** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
export interface EimsInvoiceError { export interface EimsInvoiceError {
@@ -30,6 +31,28 @@ export interface EimsInvoiceStatusView {
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */ /** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
eimsAckDate: string | null; eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | 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. */ /** `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 { useAuth } from "@/auth/useAuth";
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing"; 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 { WithPermission } from "@/shared/hooks/useHas";
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers"; import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
import TemplatePage from "@/super-admin/components/templates/components/templates"; 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 { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
import { PermissionProvider } from "@/shared/context/PermissionContext"; import { PermissionProvider } from "@/shared/context/PermissionContext";
import UserManagementPage from "@/pages/UserManagementPage"; 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 * 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" path="user-management/position-management"
element={<PositionManagementPage />} 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 <Route
path="user-management/migrated-records-management" path="user-management/migrated-records-management"
element={<MigratedDataManagementPage />} element={<MigratedDataManagementPage />}

View File

@@ -169,7 +169,8 @@ export default function OnboardingWizardDialog({
setCooperative(checked); setCooperative(checked);
if (checked) { if (checked) {
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); 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< const [documentFiles, setDocumentFiles] = useState<

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