mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY=
|
||||
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
|
||||
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
|
||||
# locally, naming the missing variables, until these are set.
|
||||
# Required, and deliberately unset: the choice is a tax position, not a default.
|
||||
# Required, and deliberately unset here: the choice is a tax position, not a default.
|
||||
# MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
|
||||
# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt).
|
||||
# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env.
|
||||
EIMS_TAX_CODE=
|
||||
EIMS_TAX_RATE_PERCENT=0
|
||||
EIMS_EXCISE_TAX_VALUE=0
|
||||
EIMS_INCOME_WITHHOLD_VALUE=0
|
||||
EIMS_TRANSACTION_WITHHOLD_VALUE=0
|
||||
# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a
|
||||
# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material.
|
||||
# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above.
|
||||
# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types.
|
||||
EIMS_TAX_CODE_BY_CHARGE_TYPE=
|
||||
EIMS_TAX_RATE_BY_CHARGE_TYPE=
|
||||
# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively.
|
||||
EIMS_EXCISE_BY_CHARGE_TYPE=
|
||||
EIMS_DISCOUNT_BY_CHARGE_TYPE=
|
||||
# Document classification and payment presentation.
|
||||
EIMS_TRANSACTION_TYPE=B2B
|
||||
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
|
||||
|
||||
@@ -112,6 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { RequestLogMiddleware } from "@edr/api-common";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||
@@ -244,6 +245,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { Repository } from "typeorm";
|
||||
import {
|
||||
BaseRepository,
|
||||
RequestLogMiddleware,
|
||||
getLogContext,
|
||||
logCtx,
|
||||
@@ -47,10 +49,13 @@ describe("logCtx", () => {
|
||||
|
||||
describe("RequestLogMiddleware", () => {
|
||||
it("emits one canonical JSON line carrying the collected context", () => {
|
||||
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
|
||||
// "[Nest] … LOG [request]" prefix in front of it.
|
||||
const lines: string[] = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, "warn")
|
||||
.mockImplementation((m) => lines.push(String(m)));
|
||||
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
lines.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||
|
||||
const listeners: Record<string, () => void> = {};
|
||||
@@ -60,10 +65,40 @@ describe("RequestLogMiddleware", () => {
|
||||
originalUrl: "/api/bookings/1/submit?dry=1",
|
||||
baseUrl: "/api/bookings",
|
||||
route: { path: "/:id/submit" },
|
||||
headers: { "user-agent": "jest", "x-request-id": "req-42" },
|
||||
headers: {
|
||||
"user-agent": "jest",
|
||||
"x-request-id": "req-42",
|
||||
authorization: "Bearer tok",
|
||||
"x-client-app": "freight-backoffice",
|
||||
"current-project-id": "proj-3",
|
||||
},
|
||||
ip: "10.0.0.1",
|
||||
query: { dry: "1" },
|
||||
user: { id: "u-7" },
|
||||
user: {
|
||||
id: "u-7",
|
||||
sessionId: "sess-9",
|
||||
userType: "STAFF",
|
||||
status: "ACTIVE",
|
||||
username: "nati",
|
||||
email: "nati@example.com",
|
||||
phoneNumber: "0911000000",
|
||||
name: { en: "Nati" },
|
||||
roles: [{ key: "freight_operations" }],
|
||||
permissions: [{ key: "a" }, { key: "b" }],
|
||||
employee: {
|
||||
id: "emp-1",
|
||||
organizationId: "org-1",
|
||||
unitId: "unit-2",
|
||||
position: {
|
||||
id: "pos-5",
|
||||
key: "ops_officer",
|
||||
employeePositionId: "ep-6",
|
||||
isDelegate: true,
|
||||
delegatorId: "pos-1",
|
||||
positionType: { key: "operations" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
statusCode: 409,
|
||||
@@ -82,7 +117,11 @@ describe("RequestLogMiddleware", () => {
|
||||
listeners.close(); // aborts/close after finish must not double-log
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].endsWith("\n")).toBe(true);
|
||||
expect(lines[0].startsWith("{")).toBe(true);
|
||||
expect(JSON.parse(lines[0])).toMatchObject({
|
||||
level: "warn",
|
||||
logger: "request",
|
||||
type: "http_request",
|
||||
requestId: "req-42",
|
||||
method: "POST",
|
||||
@@ -96,7 +135,65 @@ describe("RequestLogMiddleware", () => {
|
||||
bookingId: "b-1",
|
||||
booking: { outcome: "REJECTED" },
|
||||
});
|
||||
expect(JSON.parse(lines[0]).auth).toEqual({
|
||||
authenticated: true,
|
||||
hasBearer: true,
|
||||
clientApp: "freight-backoffice",
|
||||
userId: "u-7",
|
||||
sessionId: "sess-9",
|
||||
userType: "STAFF",
|
||||
userStatus: "ACTIVE",
|
||||
roles: ["freight_operations"],
|
||||
permissionCount: 2,
|
||||
employeeId: "emp-1",
|
||||
organizationId: "org-1",
|
||||
unitId: "unit-2",
|
||||
positionId: "pos-5",
|
||||
positionKey: "ops_officer",
|
||||
positionType: "operations",
|
||||
employeePositionId: "ep-6",
|
||||
isDelegate: true,
|
||||
delegatorId: "pos-1",
|
||||
projectId: "proj-3",
|
||||
});
|
||||
// No personal data reaches the line, whatever the token carried.
|
||||
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BaseRepository write trail", () => {
|
||||
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
|
||||
constructor(repo: Repository<{ id: string; status?: string }>) {
|
||||
super(repo);
|
||||
}
|
||||
}
|
||||
|
||||
const typeormRepo = {
|
||||
metadata: { tableName: "booking" },
|
||||
create: (data: unknown) => data,
|
||||
save: async (data: unknown) => data,
|
||||
update: async () => undefined,
|
||||
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
|
||||
softDelete: async () => undefined,
|
||||
delete: async () => undefined,
|
||||
} as unknown as Repository<{ id: string; status?: string }>;
|
||||
|
||||
it("records creates, status changes and deletes without any service opting in", async () => {
|
||||
const ctx = await runWithLogContext({}, async () => {
|
||||
const repo = new TestRepo(typeormRepo);
|
||||
await repo.create({ id: "b-1" });
|
||||
await repo.update("b-1", { status: "SUBMITTED" });
|
||||
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
|
||||
await repo.softDelete("b-1");
|
||||
return getLogContext();
|
||||
});
|
||||
|
||||
expect(ctx).toEqual({
|
||||
db: { created: { booking: 1 }, updated: { booking: 2 } },
|
||||
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
|
||||
deleted: [{ entity: "booking", id: "b-1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,8 +89,30 @@ export interface EimsInvoiceConfig {
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||
* `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax
|
||||
* treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above
|
||||
* cannot express that. Values are raw strings; the context builder parses/validates them.
|
||||
*/
|
||||
taxCodeByChargeType: Record<string, string>;
|
||||
taxRateByChargeType: Record<string, string>;
|
||||
/** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge
|
||||
* types not listed fall back to `exciseTaxValue` / 0 respectively. */
|
||||
exciseByChargeType: Record<string, string>;
|
||||
discountByChargeType: Record<string, string>;
|
||||
cashierName: string | null;
|
||||
salesPersonName: string | null;
|
||||
/**
|
||||
* TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every
|
||||
* buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be
|
||||
* one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead
|
||||
* of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits.
|
||||
* Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists.
|
||||
*/
|
||||
buyerIdType: string | null;
|
||||
buyerIdNumber: string | null;
|
||||
}
|
||||
|
||||
const REQUIRED_VARS = [
|
||||
@@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||
discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE),
|
||||
cashierName: process.env.EIMS_CASHIER_NAME || null,
|
||||
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
|
||||
buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null,
|
||||
buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
|
||||
PER_CONTAINER: 'per container',
|
||||
PER_KM: 'per km',
|
||||
PER_TON_KM: 'per ton per km',
|
||||
PER_LITER: 'per liter',
|
||||
PER_INVOICE: 'per invoice',
|
||||
FLAT: 'flat',
|
||||
};
|
||||
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
||||
DEMURRAGE: 'Demurrage / wagon detention',
|
||||
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
|
||||
CUSTOMS_CLEARANCE: 'Customs clearance service',
|
||||
FUEL: 'Fuel surcharge',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
||||
// direction belong on its schedule, labeled with their leg.
|
||||
if (rate.trigger === 'FUEL') {
|
||||
if (this.fuelDirectionMatches(rate, direction)) {
|
||||
surcharges.push(this.fuelRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||
surcharges.push(this.surchargeRow(rate));
|
||||
}
|
||||
@@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||
const want =
|
||||
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
||||
return rate.tradeDirection === want;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuel row — the lane matters, so it rides along in the charge label.
|
||||
* Per-liter collapses to one flat total (base liters × rate value); the
|
||||
* customer only ever sees the final price.
|
||||
*/
|
||||
private fuelRow(rate: Rate): RateScheduleRow {
|
||||
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||
const destination =
|
||||
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||
const perLiter = rate.rateUnit === 'PER_LITER';
|
||||
return {
|
||||
route: `Fuel surcharge (${origin} → ${destination})`,
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(
|
||||
perLiter
|
||||
? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
|
||||
: rate.rateValue,
|
||||
),
|
||||
unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
private surchargeRow(rate: Rate): RateScheduleRow {
|
||||
return {
|
||||
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
);
|
||||
});
|
||||
|
||||
@@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
{ get: () => undefined } as never, // config
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // config
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.document", () => {
|
||||
const invoiceRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260812-00001",
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
type: "freight",
|
||||
currency: "ETB",
|
||||
subtotalAmount: 100,
|
||||
taxAmount: 0,
|
||||
totalAmount: 100,
|
||||
paidAmount: 0,
|
||||
balanceAmount: 100,
|
||||
issuedAt: new Date(2026, 7, 12),
|
||||
dueAt: new Date(2026, 7, 19),
|
||||
eimsIrn: null,
|
||||
eimsSignedQr: null,
|
||||
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
|
||||
...over,
|
||||
});
|
||||
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ render } as never,
|
||||
{} as never,
|
||||
{
|
||||
get: (key: string) =>
|
||||
key === "eims"
|
||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||
: undefined,
|
||||
} as never, // config
|
||||
);
|
||||
return { service, render };
|
||||
};
|
||||
|
||||
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
|
||||
expect(model.qrImageUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
|
||||
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
|
||||
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
|
||||
});
|
||||
|
||||
it("omits the VAT row when the buyer company has none", async () => {
|
||||
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
|
||||
expect(model.summary).toContainEqual({
|
||||
label: "Seller VAT No.",
|
||||
value: "43256663343256663322",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
|
||||
const { service, render } = build(
|
||||
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
|
||||
);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
@@ -8,9 +9,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
@@ -160,6 +163,7 @@ export class BillingService {
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -383,7 +387,7 @@ export class BillingService {
|
||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const invoice = await this.findById(id);
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "INVOICE"),
|
||||
await this.toDocumentModel(invoice, "INVOICE"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,15 +400,24 @@ export class BillingService {
|
||||
);
|
||||
}
|
||||
return this.invoiceDocuments.render(
|
||||
this.toDocumentModel(invoice, "RECEIPT"),
|
||||
await this.toDocumentModel(invoice, "RECEIPT"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the
|
||||
* Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header),
|
||||
* not a payload we encode ourselves. Wrapped in a data URL, nothing more.
|
||||
*/
|
||||
private renderEimsQr(signedQr: string): string {
|
||||
return `data:image/png;base64,${signedQr}`;
|
||||
}
|
||||
|
||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||
private toDocumentModel(
|
||||
private async toDocumentModel(
|
||||
invoice: Invoice & { lines: InvoiceLine[] },
|
||||
kind: "INVOICE" | "RECEIPT",
|
||||
): InvoiceDocumentModel {
|
||||
): Promise<InvoiceDocumentModel> {
|
||||
const title = invoice.source
|
||||
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
|
||||
: "EDR";
|
||||
@@ -422,6 +435,43 @@ export class BillingService {
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
const summary: InvoiceDocumentModel["summary"] = [
|
||||
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||
...(invoice.company?.vatNumber
|
||||
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||
: []),
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this
|
||||
// codebase). Shown only when actually configured, same as the buyer VAT row.
|
||||
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||
}
|
||||
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
@@ -429,24 +479,7 @@ export class BillingService {
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: "Status", value: invoice.status },
|
||||
{ label: "Type", value: invoice.type },
|
||||
{ label: "Reference", value: invoice.sourceId },
|
||||
{ label: "Currency", value: invoice.currency },
|
||||
{
|
||||
label: "Issued",
|
||||
value: invoice.issuedAt
|
||||
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
{
|
||||
label: "Due",
|
||||
value: invoice.dueAt
|
||||
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||
: null,
|
||||
},
|
||||
],
|
||||
summary,
|
||||
categoryHeader: "Charge type",
|
||||
lines: invoice.lines.map((l) => ({
|
||||
description: l.description ?? l.chargeType,
|
||||
@@ -457,6 +490,7 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -951,6 +985,24 @@ export class BillingService {
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
// Every invoice status move in the app funnels through here — money
|
||||
// changing state is the single most-asked question in support.
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source,
|
||||
sourceId: invoice.sourceId,
|
||||
from: invoice.status,
|
||||
to: status,
|
||||
event,
|
||||
amount: Number(invoice.totalAmount),
|
||||
currency: invoice.currency,
|
||||
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
|
||||
},
|
||||
{ path: "invoiceTransitions", mode: "push" },
|
||||
);
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
return {
|
||||
result: updated,
|
||||
@@ -1320,6 +1372,21 @@ export class BillingService {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source,
|
||||
sourceId: invoice.sourceId,
|
||||
companyId: invoice.companyId,
|
||||
amountDue,
|
||||
currency: invoice.currency,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
},
|
||||
{ path: "payment.payInvoice" },
|
||||
);
|
||||
|
||||
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
|
||||
// required up front (the payment service rejects it otherwise, as a 502 here).
|
||||
if (
|
||||
@@ -1443,7 +1510,24 @@ export class BillingService {
|
||||
// first on DESC, which would hand back an unissued invoice.
|
||||
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
if (!invoice) {
|
||||
logCtx(
|
||||
{ paymentId, outcome: "no-invoice-for-payment" },
|
||||
{ path: "payment.settleInvoice" },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logCtx(
|
||||
{
|
||||
paymentId,
|
||||
providerTxnId,
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceStatus: invoice.status,
|
||||
},
|
||||
{ path: "payment.settleInvoice" },
|
||||
);
|
||||
|
||||
const settleable: Freight.InvoiceStatus[] = [
|
||||
...OPEN_STATUSES,
|
||||
@@ -1453,6 +1537,12 @@ export class BillingService {
|
||||
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
|
||||
// inline by payInvoice). Anything else means money was captured with
|
||||
// nowhere to land — that needs a person, so say so loudly.
|
||||
logCtx(
|
||||
invoice.status === Freight.InvoiceStatus.Paid
|
||||
? "already-paid"
|
||||
: "captured-with-nowhere-to-land",
|
||||
{ path: "payment.settleInvoice.outcome", mode: "set" },
|
||||
);
|
||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||
this.logger.error(
|
||||
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,12 @@ export interface InvoiceDocumentModel {
|
||||
* explicitly only to override that default for one document.
|
||||
*/
|
||||
stampImageUrl?: string | null;
|
||||
/**
|
||||
* MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` —
|
||||
* see that column's comment). Set only once an invoice is actually registered; the IRN text
|
||||
* itself goes through the ordinary `summary` rows, not a dedicated field.
|
||||
*/
|
||||
qrImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,9 +102,11 @@ export class InvoiceDocumentService {
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
// ponytail: still draws the plain vector seal, not the uploaded stamp
|
||||
// image — embedding a raster image needs a new PDF XObject primitive
|
||||
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
|
||||
// carry the real stamp too; today it's a rare degraded fallback.
|
||||
// image, and omits the EIMS QR entirely — embedding a raster image
|
||||
// needs a new PDF XObject primitive in styled-pdf.util.ts. Upgrade
|
||||
// when the Chromium-less path needs to carry the real stamp/QR too;
|
||||
// today it's a rare degraded fallback. The IRN text itself still
|
||||
// comes through (buildFallbackPdf renders model.summary same as HTML).
|
||||
fallback: () => this.buildFallbackPdf(resolvedModel),
|
||||
}),
|
||||
};
|
||||
@@ -243,6 +251,10 @@ export class InvoiceDocumentService {
|
||||
const sealInner = sealMarkup(model.stampImageUrl, sealText);
|
||||
const sealCssClass = sealClass(model.stampImageUrl);
|
||||
|
||||
const qrMarkup = model.qrImageUrl
|
||||
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><span>Scan to verify (MoR EIMS)</span></div>`
|
||||
: "";
|
||||
|
||||
const summaryRows = model.summary
|
||||
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||
.join("");
|
||||
@@ -281,8 +293,19 @@ export class InvoiceDocumentService {
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
${sealImageCss()}
|
||||
.qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; }
|
||||
.qr img { width: 90px; height: 90px; }
|
||||
.qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
/* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the
|
||||
150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */
|
||||
.summary.summary-with-qr { margin-right: 270px; }
|
||||
/* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long
|
||||
unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than
|
||||
honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay
|
||||
(confirmed by isolating the two: margin-right alone already positioned the box correctly;
|
||||
the text itself was still escaping the box's own right edge until this was added). */
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
@@ -309,7 +332,8 @@ export class InvoiceDocumentService {
|
||||
</div>
|
||||
</div>
|
||||
<div class="${sealCssClass}">${sealInner}</div>
|
||||
<div class="summary">${summaryRows}</div>
|
||||
${qrMarkup}
|
||||
<div class="summary${model.qrImageUrl ? " summary-with-qr" : ""}">${summaryRows}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -55,7 +55,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
||||
salesPersonName: null,
|
||||
transactionType: "B2B",
|
||||
payment: { mode: "CASH", term: "IMMIDIATE" },
|
||||
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }),
|
||||
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }),
|
||||
natureOfSupplies: "Service",
|
||||
unitDefault: "PCS",
|
||||
incomeWithholdValue: 0,
|
||||
@@ -115,8 +115,8 @@ describe("toEimsInvoice", () => {
|
||||
context({
|
||||
taxForLine: (line) =>
|
||||
line.chargeType === "RAIL_FREIGHT"
|
||||
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0 }
|
||||
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50 },
|
||||
? { code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: 0 }
|
||||
: { code: "EXEMPT", ratePercent: 0, exciseTaxValue: 50, discount: 25 },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -130,6 +130,7 @@ describe("toEimsInvoice", () => {
|
||||
TaxCode: "VAT15",
|
||||
TaxAmount: 1500,
|
||||
ExciseTaxValue: 0,
|
||||
Discount: 0,
|
||||
TotalLineAmount: 11500,
|
||||
Unit: "PCS",
|
||||
NatureOfSupplies: "service",
|
||||
@@ -141,6 +142,9 @@ describe("toEimsInvoice", () => {
|
||||
TaxCode: "EXEMPT",
|
||||
TaxAmount: 0,
|
||||
ExciseTaxValue: 50,
|
||||
// Discount is carried on the line but does not (yet) reduce TotalLineAmount — see the
|
||||
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
|
||||
Discount: 25,
|
||||
TotalLineAmount: 1050,
|
||||
Unit: "CTR",
|
||||
});
|
||||
@@ -187,7 +191,17 @@ describe("toEimsInvoice", () => {
|
||||
toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0 }) }),
|
||||
context({ taxForLine: () => ({ code: "", ratePercent: 15, exciseTaxValue: 0, discount: 0 }) }),
|
||||
),
|
||||
).toThrow(/unresolved tax treatment for line 1/);
|
||||
|
||||
expect(() =>
|
||||
toEimsInvoice(
|
||||
invoice(),
|
||||
seller,
|
||||
context({
|
||||
taxForLine: () => ({ code: "VAT15", ratePercent: 15, exciseTaxValue: 0, discount: NaN }),
|
||||
}),
|
||||
),
|
||||
).toThrow(/unresolved tax treatment for line 1/);
|
||||
});
|
||||
|
||||
@@ -183,6 +183,12 @@ export interface EimsLineTax {
|
||||
code: string;
|
||||
ratePercent: number;
|
||||
exciseTaxValue: number;
|
||||
/**
|
||||
* Line-level `Discount`. Its effect on `TotalLineAmount` has never been observed live (every
|
||||
* prior test ran it at 0), so the total below still sums PreTax + Tax + Excise only — do not
|
||||
* start subtracting this without a confirmed MoR example.
|
||||
*/
|
||||
discount: number;
|
||||
}
|
||||
|
||||
export interface EimsMapperContext {
|
||||
@@ -336,7 +342,13 @@ export function toEimsInvoice(
|
||||
const ItemList: EimsInvoiceItem[] = invoice.lines.map((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const tax = context.taxForLine(line, lineNumber);
|
||||
if (!tax || !tax.code || !Number.isFinite(tax.ratePercent) || !Number.isFinite(tax.exciseTaxValue)) {
|
||||
if (
|
||||
!tax ||
|
||||
!tax.code ||
|
||||
!Number.isFinite(tax.ratePercent) ||
|
||||
!Number.isFinite(tax.exciseTaxValue) ||
|
||||
!Number.isFinite(tax.discount)
|
||||
) {
|
||||
throw new Error(
|
||||
`EIMS mapping: unresolved tax treatment for line ${lineNumber} (${line.chargeType}) ` +
|
||||
`on invoice ${invoice.invoiceNumber}`,
|
||||
@@ -349,7 +361,7 @@ export function toEimsInvoice(
|
||||
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
||||
|
||||
return {
|
||||
Discount: 0,
|
||||
Discount: round2(tax.discount),
|
||||
ExciseTaxValue,
|
||||
HarmonizationCode: null,
|
||||
NatureOfSupplies: natureOfSupplies,
|
||||
|
||||
@@ -111,10 +111,22 @@ export class Invoice extends BaseEntity {
|
||||
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
||||
eimsStatus!: EimsInvoiceStatus;
|
||||
|
||||
/** Invoice Reference Number returned by EIMS. Unique across invoices (partial index). */
|
||||
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
|
||||
/**
|
||||
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
|
||||
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
|
||||
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
|
||||
*/
|
||||
@Column({ name: "eims_irn", type: "text", nullable: true })
|
||||
eimsIrn?: string | null;
|
||||
|
||||
/**
|
||||
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
|
||||
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
|
||||
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
|
||||
*/
|
||||
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
|
||||
eimsSignedQr?: string | null;
|
||||
|
||||
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
||||
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
||||
eimsDocumentNumber?: string | null;
|
||||
@@ -133,4 +145,24 @@ export class Invoice extends BaseEntity {
|
||||
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
||||
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
||||
eimsLastError?: EimsInvoiceError | null;
|
||||
|
||||
/**
|
||||
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
|
||||
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
|
||||
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
|
||||
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
|
||||
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
|
||||
*/
|
||||
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
|
||||
eimsCancelledAt?: Date | null;
|
||||
|
||||
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
|
||||
eimsCancellationDate?: string | null;
|
||||
|
||||
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
|
||||
eimsCancellationReasonCode?: string | null;
|
||||
|
||||
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
||||
eimsCancellationRemark?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { BaseRepository, logCtx } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
authorId?: string,
|
||||
): Promise<BookingReviewNote> {
|
||||
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||
// Every rejection/cancellation/change-request reason in the booking flow is
|
||||
// written through here — the "why" behind the status change on the same log
|
||||
// line as the status change itself.
|
||||
logCtx(
|
||||
{ bookingId, type, note, authorId },
|
||||
{ path: 'reviewNotes', mode: 'push' },
|
||||
);
|
||||
return repo.save(
|
||||
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
@@ -2115,6 +2115,21 @@ export class BookingsService {
|
||||
throw new NotFoundException(`Booking ${id} not found`);
|
||||
}
|
||||
|
||||
// Nearly every booking flow loads the booking through here, so this one
|
||||
// call puts the human-searchable reference + entry state on the request log
|
||||
// line for all of them. Entry state only — the write trail (statusChanges)
|
||||
// shows where it ended up.
|
||||
logCtx(
|
||||
{
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
statusAtEntry: booking.status,
|
||||
companyId: booking.companyId,
|
||||
contractId: booking.contractId ?? undefined,
|
||||
},
|
||||
{ path: "booking" },
|
||||
);
|
||||
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export class ContractTemplatesController {
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a bulk contract template for a (cargo type, customs option) pair",
|
||||
"Create a bulk contract template for a (cargo type, trade direction, customs option) combination",
|
||||
})
|
||||
create(@Body() dto: CreateContractTemplateDto) {
|
||||
return this.service.create(dto);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { IsNull, Repository } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||
import {
|
||||
BulkTemplateDirection,
|
||||
ContractTemplate,
|
||||
} from "./entities/contract-template.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||
@@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
|
||||
findByCargoCombo(
|
||||
cargoTypeId: string,
|
||||
withCustoms: boolean,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
|
||||
return this.repository.findOne({
|
||||
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The active bulk template covering this cargo type: written against the
|
||||
* cargo type itself or against its parent group (the two are mutually
|
||||
* exclusive, so at most one row matches).
|
||||
* exclusive, so at most one row matches). Intercity templates carry no
|
||||
* customs variant, so they are matched on a null flag.
|
||||
*/
|
||||
findActiveBulkTemplate(
|
||||
cargoTypeId: string,
|
||||
withCustoms: boolean,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
.where("t.is_active = true")
|
||||
.andWhere("t.with_customs = :withCustoms", { withCustoms })
|
||||
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||
.andWhere(
|
||||
withCustoms === null
|
||||
? "t.with_customs IS NULL"
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere(
|
||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
UpdateContractTemplateDto,
|
||||
} from "./dto/contract-template.dto";
|
||||
import {
|
||||
BulkTemplateDirection,
|
||||
bulkTemplateCode,
|
||||
bulkTemplateDirectionFor,
|
||||
CONTRACT_TEMPLATE_CODES,
|
||||
ContractTemplate,
|
||||
ContractTemplateArticle,
|
||||
@@ -77,10 +80,13 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-created bulk template for one (cargo type, customs option) pair.
|
||||
* The cargo type must have hasContractTemplate enabled and the combination
|
||||
* must not already exist — the same commodity + customs pairing is edited,
|
||||
* never duplicated.
|
||||
* Staff-created bulk template for one (cargo type, direction, customs
|
||||
* option) triple. The cargo type must have hasContractTemplate enabled and
|
||||
* the combination must not already exist — the same commodity + direction +
|
||||
* customs pairing is edited, never duplicated.
|
||||
*
|
||||
* Intercity is domestic and crosses no border, so it carries no customs
|
||||
* variant: the flag must be omitted and is stored as null.
|
||||
*/
|
||||
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
|
||||
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
|
||||
@@ -92,31 +98,46 @@ export class ContractTemplatesService {
|
||||
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
|
||||
);
|
||||
}
|
||||
const variant = dto.withCustoms ? "with" : "without";
|
||||
|
||||
const direction = dto.tradeDirection;
|
||||
const intercity = direction === "INTERCITY";
|
||||
if (intercity && dto.withCustoms !== undefined) {
|
||||
throw new BadRequestException(
|
||||
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||
);
|
||||
}
|
||||
if (!intercity && dto.withCustoms === undefined) {
|
||||
throw new BadRequestException(
|
||||
`A ${direction.toLowerCase()} template must state whether customs clearing is included`,
|
||||
);
|
||||
}
|
||||
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||
|
||||
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
||||
const existing = await this.repository.findByCargoCombo(
|
||||
dto.cargoTypeId,
|
||||
dto.withCustoms,
|
||||
direction,
|
||||
withCustoms,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||
`${label} already exists — edit that template instead`,
|
||||
);
|
||||
}
|
||||
|
||||
const template = new ContractTemplate();
|
||||
template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase();
|
||||
template.name =
|
||||
dto.name ??
|
||||
`${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`;
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
||||
template.name = dto.name ?? label;
|
||||
template.description = dto.description ?? null;
|
||||
template.documentTitle = dto.withCustoms
|
||||
template.documentTitle = withCustoms
|
||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||
template.whereasClauses = [];
|
||||
template.articles = [];
|
||||
template.isActive = true;
|
||||
template.cargoTypeId = cargoType.id;
|
||||
template.withCustoms = dto.withCustoms;
|
||||
template.tradeDirection = direction;
|
||||
template.withCustoms = withCustoms;
|
||||
template.isSystem = false;
|
||||
try {
|
||||
return await this.repository.saveTemplate(template);
|
||||
@@ -124,13 +145,29 @@ export class ContractTemplatesService {
|
||||
// Partial unique index backstop for concurrent creates of the same combo.
|
||||
if ((error as { code?: string })?.code === "23505") {
|
||||
throw new ConflictException(
|
||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||
`${label} already exists — edit that template instead`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for one bulk combination, used for names and conflict errors. */
|
||||
private comboLabel(
|
||||
cargoTypeName: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): string {
|
||||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||
const customs =
|
||||
withCustoms === null
|
||||
? ""
|
||||
: withCustoms
|
||||
? ", with customs clearing"
|
||||
: ", without customs clearing";
|
||||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||
}
|
||||
|
||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||
async remove(code: string): Promise<void> {
|
||||
const template = await this.getByCode(code);
|
||||
@@ -146,9 +183,10 @@ export class ContractTemplatesService {
|
||||
* The active template used when generating a contract document. Container
|
||||
* contracts resolve through the fixed direction/customs codes; bulk contracts
|
||||
* resolve through the staff-created template for the contract's cargo type
|
||||
* (or its parent group) and customs option. Null when nothing matches or the
|
||||
* match is deactivated (the renderer then falls back to the built-in generic
|
||||
* layout).
|
||||
* (or its parent group), trade direction and customs option. A domestic
|
||||
* contract resolves to the intercity template regardless of its customs flag.
|
||||
* Null when nothing matches or the match is deactivated (the renderer then
|
||||
* falls back to the built-in generic layout).
|
||||
*/
|
||||
async findActiveForContract(
|
||||
tradeDirection?: string | null,
|
||||
@@ -159,9 +197,11 @@ export class ContractTemplatesService {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
if (!cargoTypeId) return null;
|
||||
const direction = bulkTemplateDirectionFor(tradeDirection);
|
||||
return this.repository.findActiveBulkTemplate(
|
||||
cargoTypeId,
|
||||
Boolean(customsClearingEnabled),
|
||||
direction,
|
||||
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||
);
|
||||
}
|
||||
const code = contractTemplateCodeFor(
|
||||
@@ -274,18 +314,31 @@ export class ContractTemplatesService {
|
||||
|
||||
/**
|
||||
* Registry key the mock preview renders against. Staff-created bulk
|
||||
* templates aren't in the fixed code map — they preview against the
|
||||
* representative bulk import pack matching their customs option.
|
||||
* templates aren't in the fixed code map — they preview against the bulk
|
||||
* pack matching their own direction and customs option.
|
||||
*/
|
||||
private previewKeyFor(template: ContractTemplate): string {
|
||||
if (template.cargoTypeId) {
|
||||
return template.withCustoms
|
||||
? "IMP_BULK_USD_FORWARDING"
|
||||
: "IMP_BULK_USD_TRANSPORT_ONLY";
|
||||
const direction = bulkTemplateDirectionFor(template.tradeDirection);
|
||||
const dir =
|
||||
direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM";
|
||||
const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY";
|
||||
return `${dir}_BULK_USD_${scope}`;
|
||||
}
|
||||
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview direction: bulk templates carry it on the row, the fixed container
|
||||
* codes carry it as the code prefix.
|
||||
*/
|
||||
private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection {
|
||||
if (template.cargoTypeId) {
|
||||
return bulkTemplateDirectionFor(template.tradeDirection);
|
||||
}
|
||||
return bulkTemplateDirectionFor(template.code.split("_")[0]);
|
||||
}
|
||||
|
||||
private buildMockView(
|
||||
template: ContractTemplate,
|
||||
dynamicTemplate: ContractDynamicTemplateView,
|
||||
@@ -294,11 +347,12 @@ export class ContractTemplatesService {
|
||||
const previewKey = this.previewKeyFor(template);
|
||||
const meta = getTemplateMeta(previewKey);
|
||||
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||
const direction = this.previewDirectionFor(template);
|
||||
const now = new Date();
|
||||
|
||||
// Representative rate schedule so the admin preview shows the live-rate
|
||||
// table shape. Real contracts populate this from freight.rates (LIVE).
|
||||
const rateSchedule = this.mockRateSchedule(code, isBulk);
|
||||
const rateSchedule = this.mockRateSchedule(direction, isBulk);
|
||||
|
||||
return {
|
||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||
@@ -336,11 +390,7 @@ export class ContractTemplatesService {
|
||||
schedule: {
|
||||
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
|
||||
destinationLabel: "Galaan Multipurpose Port (GMP)",
|
||||
tradeDirection: code.startsWith("IMPORT")
|
||||
? "IMPORT"
|
||||
: code.startsWith("EXPORT")
|
||||
? "EXPORT"
|
||||
: "DOMESTIC",
|
||||
tradeDirection: direction === "INTERCITY" ? "DOMESTIC" : direction,
|
||||
freightType: isBulk ? "BULK" : "CONTAINER",
|
||||
serviceType: "Rail transport and customs clearance",
|
||||
scheduledDate: "—",
|
||||
@@ -379,16 +429,14 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/** Static, representative rate schedule for the admin preview only. */
|
||||
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
|
||||
const dir = code.startsWith("IMPORT")
|
||||
? "import"
|
||||
: code.startsWith("EXPORT")
|
||||
? "export"
|
||||
: "domestic";
|
||||
private mockRateSchedule(
|
||||
direction: BulkTemplateDirection,
|
||||
isBulk: boolean,
|
||||
): RateSchedule {
|
||||
const lane =
|
||||
dir === "export"
|
||||
direction === "EXPORT"
|
||||
? "Galaan Multipurpose Port → SGTD"
|
||||
: dir === "domestic"
|
||||
: direction === "INTERCITY"
|
||||
? "Mojo Dry Port → Dire Dawa"
|
||||
: "Negad → Mojo Dry Port";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import {
|
||||
BULK_TEMPLATE_DIRECTIONS,
|
||||
BulkTemplateDirection,
|
||||
} from "../entities/contract-template.entity";
|
||||
|
||||
export class CreateContractTemplateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
@@ -23,10 +29,19 @@ export class CreateContractTemplateDto {
|
||||
cargoTypeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Whether this is the with-customs-clearing variant",
|
||||
description: "Trade direction this template is written for",
|
||||
enum: BULK_TEMPLATE_DIRECTIONS,
|
||||
})
|
||||
@IsIn(BULK_TEMPLATE_DIRECTIONS as unknown as string[])
|
||||
tradeDirection!: BulkTemplateDirection;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Whether this is the with-customs-clearing variant. Required for IMPORT/EXPORT, rejected for INTERCITY (domestic movements cross no border)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
withCustoms!: boolean;
|
||||
withCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
|
||||
@@ -9,10 +9,10 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
* template). These are system rows: always present, never deletable.
|
||||
*
|
||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||
* (`cargoTypeId`) and customs option (`withCustoms`), one template per
|
||||
* combination. Their codes are generated as BULK_<cargo code>_(NO_)CUSTOMS.
|
||||
* The retired direction-keyed bulk codes remain listed so old frozen document
|
||||
* snapshots still label correctly.
|
||||
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
||||
* (`withCustoms`), one template per combination. Their codes are generated by
|
||||
* `bulkTemplateCode` below. The retired direction-keyed bulk codes remain
|
||||
* listed so old frozen document snapshots still label correctly.
|
||||
*
|
||||
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||
@@ -82,9 +82,41 @@ export function contractTemplateCodeFor(
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
/** The three directions a bulk template can be written for. */
|
||||
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
|
||||
|
||||
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
|
||||
|
||||
/**
|
||||
* Contracts store DOMESTIC for intercity movements; templates use INTERCITY.
|
||||
* Anything that is not an explicit IMPORT/EXPORT is domestic, matching
|
||||
* `contractTemplateCodeFor`.
|
||||
*/
|
||||
export function bulkTemplateDirectionFor(
|
||||
tradeDirection?: string | null,
|
||||
): BulkTemplateDirection {
|
||||
const value = (tradeDirection ?? "").toUpperCase();
|
||||
return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated code for a staff-created bulk template. Intercity gets no customs
|
||||
* suffix — it crosses no border, so the variant does not exist.
|
||||
*/
|
||||
export function bulkTemplateCode(
|
||||
cargoCode: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): string {
|
||||
const suffix =
|
||||
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
||||
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "contract_templates" })
|
||||
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
||||
// (cargo_type_id, with_customs) for staff-created bulk templates.
|
||||
// (cargo_type_id, trade_direction, coalesce(with_customs,false)) for
|
||||
// staff-created bulk templates.
|
||||
@Index(["code"])
|
||||
export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "code", type: "varchar", length: 80 })
|
||||
@@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity {
|
||||
@JoinColumn({ name: "cargo_type_id" })
|
||||
cargoType?: CargoType | null;
|
||||
|
||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
|
||||
@Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true })
|
||||
tradeDirection?: BulkTemplateDirection | null;
|
||||
|
||||
/**
|
||||
* Bulk templates only: whether this is the with-customs-clearing variant.
|
||||
* Always null for INTERCITY templates — domestic movements have no customs
|
||||
* leg, so neither variant applies.
|
||||
*/
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
|
||||
@@ -157,13 +157,9 @@ export class ContractBookingService {
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
|
||||
// Validity window must still be open.
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||
// letting the customer re-book within contract validity (doc §10.4).
|
||||
@@ -441,12 +437,9 @@ export class ContractBookingService {
|
||||
// The customer initiates his own shipment instance on ONE_TIME contracts
|
||||
// (customs or self-clearance); GL may also initiate on a customs contract.
|
||||
// GENERAL customs instances come from a shipment request, not from here.
|
||||
await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
||||
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
|
||||
// slot from the moment it is initiated (it is not a terminal status). The
|
||||
// split chain is the one exception — a paid partial frees the slot and
|
||||
@@ -545,9 +538,7 @@ export class ContractBookingService {
|
||||
'Shipment-request initiation applies only to general customs contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
await this.assertNotExpired(contract);
|
||||
|
||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||
|
||||
@@ -681,9 +672,11 @@ export class ContractBookingService {
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
// No expiry gate here on purpose: this booking was already initiated
|
||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||
// already checked expiry at start). Finishing an in-flight booking must
|
||||
// proceed even if the contract expires meanwhile — only starting a NEW
|
||||
// booking is blocked (see assertNotExpired).
|
||||
|
||||
// Completion is booking time: the route's booking window must be open —
|
||||
// the same config-driven gate a direct one-time booking passes at create.
|
||||
@@ -1019,6 +1012,22 @@ export class ContractBookingService {
|
||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
/**
|
||||
* Blocks starting a NEW booking (create/initiate) once the contract has
|
||||
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
|
||||
* for the nightly sweep. Only for the "start something new" entry points —
|
||||
* a booking already underway (completeUnderContract) must be allowed to
|
||||
* finish even if the contract expires mid-flight.
|
||||
*/
|
||||
private async assertNotExpired(contract: Contract): Promise<void> {
|
||||
if (!isEffectivelyExpired(contract)) return;
|
||||
if (contract.status !== 'EXPIRED') {
|
||||
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
|
||||
if (flipped) contract.status = 'EXPIRED';
|
||||
}
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
private async assertGate(
|
||||
contract: Contract,
|
||||
isGlActor: boolean,
|
||||
|
||||
@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
|
||||
export interface ContractUnitRateLineItem {
|
||||
code: string;
|
||||
label: string;
|
||||
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||
unit:
|
||||
| 'per_container'
|
||||
| 'per_wagon'
|
||||
| 'per_ton'
|
||||
| 'per_item'
|
||||
| 'per_km'
|
||||
| 'per_liter'
|
||||
| 'flat';
|
||||
unitPrice: number;
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
return 'per_wagon';
|
||||
case 'PER_CONTAINER':
|
||||
return 'per_container';
|
||||
case 'PER_LITER':
|
||||
return 'per_liter';
|
||||
default:
|
||||
return 'flat';
|
||||
}
|
||||
@@ -276,6 +285,42 @@ export class ContractPricingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fuel surcharge — shown when the contract's commodity incurs fuel
|
||||
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
|
||||
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
|
||||
// this line freezes the agreed unit price.
|
||||
{
|
||||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
if (scope?.cargoType?.hasFuel && route) {
|
||||
const fuel = liveRates.find(
|
||||
(r) =>
|
||||
r.trigger === 'FUEL' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId &&
|
||||
r.cargoTypeId === scope.cargoTypeId,
|
||||
);
|
||||
if (fuel && Number(fuel.rateValue) > 0) {
|
||||
// Per-liter collapses to one flat total (base liters × rate value) —
|
||||
// the customer only sees the final price, and booking pricing bills
|
||||
// the same flat figure once (see RuleEngineService.fuelCharges).
|
||||
const perLiter = fuel.rateUnit === 'PER_LITER';
|
||||
const total = perLiter
|
||||
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
|
||||
: Number(fuel.rateValue);
|
||||
lineItems.push({
|
||||
code: 'FUEL_SURCHARGE',
|
||||
label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
|
||||
unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
|
||||
unitPrice: convert(total),
|
||||
cargoTypeCode: scope.cargoType.code ?? null,
|
||||
conditionalOn: 'has_fuel',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty-container return service — container contracts only, toggled on the
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
||||
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
|
||||
): Promise<void> {
|
||||
const role = dto.role as ContractSignerRole;
|
||||
|
||||
// Both sign() and counterSign() land here — who signed what, and whether the
|
||||
// ink came from the request or the signer's saved profile signature.
|
||||
logCtx(
|
||||
{
|
||||
contractId: contract.id,
|
||||
reference: contract.reference,
|
||||
role,
|
||||
signerUserId: options.signerUserId,
|
||||
usedDrawnImage: Boolean(dto.signatureImageBase64),
|
||||
},
|
||||
{ path: "contractSignatures", mode: "push" },
|
||||
);
|
||||
|
||||
// Resolve the signature image. The client may send a freshly-drawn image, or
|
||||
// omit it to reuse the signer's saved profile signature. Fall back to the
|
||||
// saved one whenever no image is supplied.
|
||||
|
||||
@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-row version of expireLapsedContracts, for lazy flips on read/booking
|
||||
* paths — flips this one contract to EXPIRED if it's lapsed and not already
|
||||
* terminal. No-op (returns false) if the contract isn't actually lapsed, so
|
||||
* callers can call this unconditionally without a pre-check.
|
||||
*/
|
||||
async expireIfLapsed(id: string): Promise<boolean> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(Contract)
|
||||
.set({ status: 'EXPIRED' })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('deleted_at IS NULL')
|
||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||
now: new Date(),
|
||||
})
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live contracts whose validity ends between `days` and `days + 1` days from
|
||||
* now — the slice the daily expiry-reminder cron warns about. The window is
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
|
||||
import { YardCountry } from '@edr/types';
|
||||
//
|
||||
@@ -811,6 +811,27 @@ export class ContractsService {
|
||||
throw new NotFoundException(`Contract ${id} not found`);
|
||||
}
|
||||
|
||||
// Lazy expiry flip: the nightly cron only sweeps once a day, so a
|
||||
// contract can be past contract_valid_until for hours before it shows
|
||||
// EXPIRED. Flip it here so the detail page never shows a stale status.
|
||||
if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') {
|
||||
const flipped = await this.contractsRepository.expireIfLapsed(id);
|
||||
if (flipped) {
|
||||
contract.status = 'EXPIRED';
|
||||
}
|
||||
}
|
||||
// Entry state for every contract flow (submit, approve, sign, suspend…) —
|
||||
// see the equivalent in BookingsService.findById.
|
||||
logCtx(
|
||||
{
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
statusAtEntry: contract.status,
|
||||
companyId: contract.companyId,
|
||||
},
|
||||
{ path: "contract" },
|
||||
);
|
||||
|
||||
if (contract.files && contract.files.length > 0) {
|
||||
contract.files = await Promise.all(
|
||||
contract.files.map(async (file: FileRecord) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,9 +10,11 @@ export class ResolveEimsRegistrationDto {
|
||||
description: "IRN confirmed in the MoR portal. Records the registration and resumes the chain.",
|
||||
example: "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0",
|
||||
})
|
||||
// Not capped at 64: eims_irn is `text` now — a real IRN already overflowed a former
|
||||
// varchar(64) (69 chars). This bound is a sanity ceiling, not a confirmed MoR format.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
@Length(1, 500)
|
||||
irn?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,47 @@ export function assertEimsInvoiceConfig(config: EimsConfig): void {
|
||||
}
|
||||
|
||||
assertSellerFormats(config.invoice);
|
||||
assertChargeTypeOverrides(config.invoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-`chargeType` tax overrides must be internally consistent before anything is filed:
|
||||
* `EIMS_TAX_CODE_BY_CHARGE_TYPE` and `EIMS_TAX_RATE_BY_CHARGE_TYPE` must name the same charge
|
||||
* types (a code with no rate, or vice versa, is a half-finished override), and every rate/excise/
|
||||
* discount value must parse as a number — checked once here rather than once per line at
|
||||
* registration time.
|
||||
*/
|
||||
function assertChargeTypeOverrides(invoice: EimsConfig["invoice"]): void {
|
||||
const codeKeys = Object.keys(invoice.taxCodeByChargeType);
|
||||
const rateKeys = Object.keys(invoice.taxRateByChargeType);
|
||||
const mismatched = [...new Set([...codeKeys, ...rateKeys])].filter(
|
||||
(k) => !(codeKeys.includes(k) && rateKeys.includes(k)),
|
||||
);
|
||||
if (mismatched.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||
message:
|
||||
`EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge ` +
|
||||
`types; mismatched: ${mismatched.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const numericMaps: { env: string; map: Record<string, string> }[] = [
|
||||
{ env: "EIMS_TAX_RATE_BY_CHARGE_TYPE", map: invoice.taxRateByChargeType },
|
||||
{ env: "EIMS_EXCISE_BY_CHARGE_TYPE", map: invoice.exciseByChargeType },
|
||||
{ env: "EIMS_DISCOUNT_BY_CHARGE_TYPE", map: invoice.discountByChargeType },
|
||||
];
|
||||
const badNumbers = numericMaps.flatMap(({ env, map }) =>
|
||||
Object.entries(map)
|
||||
.filter(([, value]) => !Number.isFinite(Number(value)))
|
||||
.map(([chargeType]) => `${env}[${chargeType}]`),
|
||||
);
|
||||
if (badNumbers.length > 0) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_INVOICE_CONFIG_INVALID",
|
||||
message: `EIMS charge-type overrides must be numbers: ${badNumbers.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,9 +176,25 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
salesPersonName: invoice.salesPersonName,
|
||||
transactionType: invoice.transactionType,
|
||||
payment: { mode: invoice.paymentMode, term: invoice.paymentTerm },
|
||||
// One treatment for every line today. The mapper resolves tax per line, so a future
|
||||
// charge-type-specific rule slots in here without touching the mapper.
|
||||
taxForLine: (_line: EimsMapperLine) => ({ code: taxCode, ratePercent, exciseTaxValue }),
|
||||
// Per-`chargeType` override when one is configured (validated symmetric in
|
||||
// assertChargeTypeOverrides), else the single invoice-wide default.
|
||||
taxForLine: (line: EimsMapperLine) => {
|
||||
const { chargeType } = line;
|
||||
const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode;
|
||||
const rate =
|
||||
chargeType in invoice.taxRateByChargeType
|
||||
? Number(invoice.taxRateByChargeType[chargeType])
|
||||
: ratePercent;
|
||||
const excise =
|
||||
chargeType in invoice.exciseByChargeType
|
||||
? Number(invoice.exciseByChargeType[chargeType])
|
||||
: exciseTaxValue;
|
||||
const discount =
|
||||
chargeType in invoice.discountByChargeType
|
||||
? Number(invoice.discountByChargeType[chargeType])
|
||||
: 0;
|
||||
return { code, ratePercent: rate, exciseTaxValue: excise, discount };
|
||||
},
|
||||
natureOfSupplies: invoice.natureOfSupplies,
|
||||
unitDefault: invoice.unitDefault,
|
||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||
@@ -145,6 +202,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
||||
buyerCountryCode: invoice.buyerCountryCode,
|
||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||
buyerIdType: invoice.buyerIdType,
|
||||
buyerIdNumber: invoice.buyerIdNumber,
|
||||
exchangeRate: input.exchangeRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
|
||||
import { eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsApiException } from "./eims.errors";
|
||||
@@ -85,6 +86,8 @@ class FakeDb {
|
||||
state: EimsSystemState | null = null;
|
||||
/** Runs before every transaction body, to simulate a concurrent writer. */
|
||||
onTransaction: (() => void) | null = null;
|
||||
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
|
||||
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||
|
||||
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
|
||||
for (const inv of invoices) this.invoices.set(inv.id, inv);
|
||||
@@ -133,10 +136,13 @@ class FakeDb {
|
||||
return {
|
||||
manager: this.manager,
|
||||
getRepository: this.manager.getRepository,
|
||||
query: async (sql: string) =>
|
||||
sql.includes("eims_system_state")
|
||||
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
|
||||
: LINES,
|
||||
query: async (sql: string) => {
|
||||
if (sql.includes("eims_system_state")) {
|
||||
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
|
||||
}
|
||||
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
|
||||
return LINES;
|
||||
},
|
||||
transaction: async (body: (m: unknown) => Promise<unknown>) => {
|
||||
this.onTransaction?.();
|
||||
return body(this.manager);
|
||||
@@ -155,6 +161,7 @@ const build = (
|
||||
postBearer: jest.Mock = jest.fn(),
|
||||
getSessionContext: jest.Mock | undefined = undefined,
|
||||
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
) =>
|
||||
new EimsInvoiceRegistrationService(
|
||||
db.asDataSource(),
|
||||
@@ -164,6 +171,7 @@ const build = (
|
||||
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
|
||||
} as unknown as EimsAuthService,
|
||||
{ notify } as unknown as NotificationInboxService,
|
||||
{ directSend } as unknown as NotificationsService,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -196,8 +204,11 @@ const verifyResponse = (over: Record<string, unknown> = {}) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const okResponse = (irn = IRN) =>
|
||||
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
||||
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
|
||||
statusCode: 200,
|
||||
message: "SUCCESS",
|
||||
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
|
||||
});
|
||||
|
||||
const apiError = (kind: string, status?: number) =>
|
||||
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
|
||||
@@ -226,6 +237,51 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists signedQR alongside the IRN", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
|
||||
|
||||
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(view.eimsSignedQr).toBe("signed-qr-payload");
|
||||
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
|
||||
});
|
||||
|
||||
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(view.eimsSignedQr).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
|
||||
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
||||
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
|
||||
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
||||
.registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
|
||||
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
|
||||
});
|
||||
|
||||
it("does not fail registration when the buyer notification itself fails", async () => {
|
||||
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
|
||||
db.companyContact = { phone: "+251911000000", email: null };
|
||||
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
|
||||
.registerInvoiceWithEims(INVOICE_ID);
|
||||
|
||||
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||
});
|
||||
|
||||
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
|
||||
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
@@ -408,7 +464,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
expect(postSigned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
|
||||
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
|
||||
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
|
||||
const postSigned = jest
|
||||
.fn()
|
||||
@@ -421,14 +477,47 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
||||
);
|
||||
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
|
||||
|
||||
// The two numbers move differently, because MoR constrains them differently: the counter must
|
||||
// not skip (it returns), the document number must not repeat (it is burned).
|
||||
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
|
||||
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
|
||||
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
|
||||
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
|
||||
expect(first.SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect(second.SourceSystem.InvoiceCounter).toBe(7);
|
||||
expect(first.DocumentDetails.DocumentNumber).toBe("5");
|
||||
expect(second.DocumentDetails.DocumentNumber).toBe("6");
|
||||
expect(second.DocumentDetails.DocumentNumber).toBe("5");
|
||||
});
|
||||
|
||||
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
|
||||
const db = new FakeDb([invoiceRow()]);
|
||||
const notify = jest.fn().mockResolvedValue(undefined);
|
||||
const postSigned = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
|
||||
// that actually failed live (eims_irn too narrow for the real value MoR returned).
|
||||
let call = 0;
|
||||
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
|
||||
const realUpdate = manager.update;
|
||||
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
|
||||
call++;
|
||||
if (call === 3) throw new Error("value too long for type character varying(64)");
|
||||
return realUpdate(entity, id, patch);
|
||||
});
|
||||
|
||||
await expect(
|
||||
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
|
||||
INVOICE_ID,
|
||||
),
|
||||
).rejects.toThrow(/value too long/);
|
||||
|
||||
// The IRN is never lost, even though the normal success path never committed.
|
||||
expect(db.state?.blockedReason).toContain(IRN);
|
||||
expect(db.state?.blockedReason).toContain("ACCEPTED");
|
||||
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const sent = notify.mock.calls[0][0];
|
||||
expect(sent.priority).toBe("HIGH");
|
||||
expect(sent.body).toContain(IRN);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
} from "../billing/eims-invoice.mapper";
|
||||
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
|
||||
private readonly client: EimsClientService,
|
||||
private readonly auth: EimsAuthService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
private get cfg(): EimsConfig {
|
||||
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
|
||||
|
||||
let irn: string;
|
||||
let ackDate: string | undefined;
|
||||
let signedQR: string | undefined;
|
||||
try {
|
||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||
const result = await this.submit(request);
|
||||
irn = result.irn;
|
||||
ackDate = result.ackDate;
|
||||
signedQR = result.signedQR;
|
||||
} catch (err) {
|
||||
await this.settleFailure(invoiceId, reservation, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
|
||||
try {
|
||||
await this.settleSuccess(invoiceId, reservation, irn, ackDate, signedQR);
|
||||
} catch (err) {
|
||||
// MoR already accepted this document — unlike settleFailure's targets, this is not
|
||||
// ambiguous, it is a *known* IRN we simply failed to persist (confirmed live 2026-08-12: a
|
||||
// column too narrow for a real IRN). Losing it here would be worse than the persistence
|
||||
// bug itself, so it goes straight into the block reason and the alert, not just a log line.
|
||||
await this.blockOnKnownIrnPersistFailure(invoiceId, reservation, irn, ackDate, err);
|
||||
throw err;
|
||||
}
|
||||
this.logger.log(
|
||||
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
|
||||
);
|
||||
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
|
||||
reservation: Reservation,
|
||||
irn: string,
|
||||
ackDate?: string,
|
||||
signedQR?: string,
|
||||
): Promise<void> {
|
||||
let companyId: string | undefined;
|
||||
let invoiceNumber = invoiceId;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.lockInvoice(manager, invoiceId);
|
||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
||||
companyId = invoice.companyId;
|
||||
invoiceNumber = invoice.invoiceNumber;
|
||||
await manager.update(Invoice, invoiceId, {
|
||||
eimsStatus: EimsInvoiceStatus.Registered,
|
||||
eimsIrn: irn,
|
||||
eimsAckDate: ackDate ?? null,
|
||||
eimsSignedQr: signedQR ?? null,
|
||||
eimsLastError: null,
|
||||
});
|
||||
await manager.update(EimsSystemState, reservation.stateId, {
|
||||
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
|
||||
blockedReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
// Best-effort, outside the transaction: MoR checklist ADD-N001 wants the buyer notified of a
|
||||
// registration event. Never lets a notification failure mask a filing that already succeeded.
|
||||
if (companyId) {
|
||||
try {
|
||||
await sendCompanyChannels(
|
||||
this.dataSource,
|
||||
this.notifications,
|
||||
companyId,
|
||||
`Invoice ${invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`EIMS buyer notification failed for invoice ${invoiceId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
|
||||
* TX2b. A deterministic rejection releases the reservation **and returns both numbers**; an
|
||||
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
||||
* for every later document.
|
||||
*
|
||||
* The two numbers move differently, because MoR constrains them differently:
|
||||
* Both `InvoiceCounter` and `DocumentNumber` roll back together on a deterministic rejection —
|
||||
* MoR's own expected-next-value only advances on acceptance, for both fields:
|
||||
*
|
||||
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
|
||||
* document MoR definitively refused was never counted there, so ours must not advance either.
|
||||
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
|
||||
* unique". It is therefore spent by the attempt itself and never handed back, even for a
|
||||
* refusal.
|
||||
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
|
||||
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
|
||||
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
|
||||
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
|
||||
* not unique" rejection; that turned out to describe the same constraint from the other side
|
||||
* (MoR rejects both reuse *and* skipping ahead of its true next value), and burning forward on
|
||||
* every rejection permanently drifted past what MoR would ever accept again — confirmed live
|
||||
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
|
||||
*
|
||||
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
||||
*/
|
||||
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
|
||||
reservation.stateId,
|
||||
deterministic
|
||||
? {
|
||||
// Counter returns (MoR never counted a refused document); the document number does
|
||||
// not (MoR requires it to be unique, so it is burned by the attempt).
|
||||
// Both return: MoR never counted a refused document against either sequence.
|
||||
nextInvoiceCounter: reservation.invoiceCounter,
|
||||
nextDocumentNumber: Number(reservation.documentNumber),
|
||||
inFlightInvoiceId: null,
|
||||
inFlightCounter: null,
|
||||
inFlightDocumentNumber: null,
|
||||
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
|
||||
await this.alertStaff(invoiceId, status, lastError, deterministic);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR accepted the document (a real IRN came back) but recording that locally failed — the
|
||||
* reservation is still held from TX1, so the system-wide block goes on regardless of *why*
|
||||
* `settleSuccess` failed. The IRN and ack date are written straight into `blockedReason` so a
|
||||
* human resolving this never has to dig through logs for the one thing that must not be lost.
|
||||
*/
|
||||
private async blockOnKnownIrnPersistFailure(
|
||||
invoiceId: string,
|
||||
reservation: Reservation,
|
||||
irn: string,
|
||||
ackDate: string | undefined,
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
const reason =
|
||||
`Invoice ${invoiceId} was ACCEPTED by EIMS (IRN ${irn}${ackDate ? `, ack ${ackDate}` : ""}) ` +
|
||||
`but recording it locally failed: ${(err as Error)?.message ?? "unknown error"}. Resolve with ` +
|
||||
`POST /invoices/${invoiceId}/eims/resolve using this IRN once the underlying issue is fixed — ` +
|
||||
"do not resubmit, the document already exists at MoR.";
|
||||
|
||||
try {
|
||||
await this.dataSource.manager.update(EimsSystemState, reservation.stateId, { blockedReason: reason });
|
||||
} catch (updateErr) {
|
||||
// Even the block itself failed to write — last resort is the log, since there is nothing
|
||||
// left to retry into.
|
||||
this.logger.error(`Could not record EIMS block for invoice ${invoiceId}: ${reason}`, updateErr as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(reason);
|
||||
// Not alertStaff(): its canned copy for a non-deterministic result always says "the IRN is
|
||||
// unknown", which is false here — the whole point of this path is that the IRN *is* known.
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
priority: NotificationPriority.HIGH,
|
||||
title: "EIMS accepted an invoice but it was not recorded — all further filing is blocked",
|
||||
body: reason,
|
||||
link: `/dashboard/invoices/${invoiceId}`,
|
||||
data: { invoiceId, eimsStatus: EimsInvoiceStatus.Unknown, irn, action: "EIMS_PERSIST_FAILED" },
|
||||
});
|
||||
} catch (notifyErr) {
|
||||
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(notifyErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the people who can act about a failed filing.
|
||||
*
|
||||
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
|
||||
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
|
||||
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
|
||||
private async submit(
|
||||
request: EimsInvoiceRequest,
|
||||
): Promise<{ irn: string; ackDate?: string; signedQR?: string }> {
|
||||
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
||||
"/v1/register",
|
||||
request,
|
||||
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
|
||||
response?.statusCode,
|
||||
);
|
||||
}
|
||||
return { irn, ackDate: response.body?.ackDate };
|
||||
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
|
||||
}
|
||||
|
||||
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
||||
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
|
||||
}
|
||||
|
||||
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
||||
const counter = invoice.eimsInvoiceCounter;
|
||||
return {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
|
||||
eimsIrn: invoice.eimsIrn ?? null,
|
||||
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
|
||||
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
|
||||
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
|
||||
eimsAckDate: invoice.eimsAckDate ?? null,
|
||||
eimsLastError: invoice.eimsLastError ?? null,
|
||||
};
|
||||
return toEimsInvoiceStatusView(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,13 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
|
||||
/**
|
||||
* Manual EIMS actions on an existing invoice.
|
||||
@@ -13,10 +18,12 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
||||
* normal production path — they exist for controlled testing and exceptional operations. Automatic
|
||||
* submission after an invoice is issued is a separate phase; nothing here is called by it.
|
||||
*
|
||||
* `eims_register` and `eims_resolve` are intentionally left out of every role preset and assigned
|
||||
* to named admins instead. They are also separate permissions: resolving clears the system-wide
|
||||
* chain block and can record an IRN against an invoice, which is a supervisor action, not an
|
||||
* operational one. Only `eims/status` rides on the ordinary `invoices:view`.
|
||||
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
|
||||
* out of every role preset and assigned to named admins instead. They are also separate
|
||||
* permissions: resolving clears the system-wide chain block and can record an IRN against an
|
||||
* invoice, cancelling is its own irreversible-at-MoR action, and filing a receipt is a third —
|
||||
* none follows from the right to register. Only `eims/status` and `eims/receipts` ride on the
|
||||
* ordinary `invoices:view`.
|
||||
*
|
||||
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
|
||||
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
|
||||
@@ -27,7 +34,11 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv
|
||||
@ApiBearerAuth()
|
||||
@Controller("invoices")
|
||||
export class EimsInvoiceController {
|
||||
constructor(private readonly registration: EimsInvoiceRegistrationService) {}
|
||||
constructor(
|
||||
private readonly registration: EimsInvoiceRegistrationService,
|
||||
private readonly cancellation: EimsCancellationService,
|
||||
private readonly receipts: EimsReceiptService,
|
||||
) {}
|
||||
|
||||
@Post(":id/eims/register")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
|
||||
@@ -65,4 +76,38 @@ export class EimsInvoiceController {
|
||||
status(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.registration.getEimsStatus(id);
|
||||
}
|
||||
|
||||
@Post(":id/eims/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.",
|
||||
})
|
||||
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
|
||||
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||
}
|
||||
|
||||
@Post(":id/eims/receipt/sales")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
|
||||
registerSalesReceipt(@Param("id", ParseUUIDPipe) id: string, @Body() dto: RegisterSalesReceiptDto) {
|
||||
return this.receipts.registerSalesReceipt(id, dto);
|
||||
}
|
||||
|
||||
@Post(":id/eims/receipt/withholding")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||
@ApiOperation({ summary: "Register a withholding receipt with MoR EIMS against a registered invoice" })
|
||||
registerWithholdingReceipt(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RegisterWithholdingReceiptDto,
|
||||
) {
|
||||
return this.receipts.registerWithholdingReceipt(id, dto);
|
||||
}
|
||||
|
||||
@Get(":id/eims/receipts")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||
@ApiOperation({ summary: "List every EIMS receipt filed against this invoice, newest first" })
|
||||
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.receipts.listReceipts(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
259
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal file
259
apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal file
97
apps/edr-freight-api/src/modules/eims/eims-receipt.types.ts
Normal 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;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export enum EimsInvoiceStatus {
|
||||
Registered = "REGISTERED",
|
||||
Failed = "FAILED",
|
||||
Unknown = "UNKNOWN",
|
||||
/** Successfully cancelled at MoR via `POST /v1/cancel`. Terminal — never re-registered. */
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** `body` of a successful `POST /v1/register`, as observed in the collection. */
|
||||
@@ -65,6 +67,28 @@ export interface EimsVerifyResponse {
|
||||
body?: EimsVerifyResponseBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/cancel` — bearer-authenticated but unsigned, same shape as `/v1/verify` (no
|
||||
* `{request,signature,certificate}` envelope in the collection's saved request).
|
||||
*/
|
||||
export interface EimsCancelRequest {
|
||||
Irn: string;
|
||||
/** Numeric code as a string, e.g. "1" (Duplicate), "6" (Calculation Error) per the collection docs. */
|
||||
ReasonCode: string;
|
||||
Remark: string;
|
||||
}
|
||||
|
||||
/** Success body: `{ cancellationDate: "Sun Dec 22 21:55:03 EAT 2024" }` — a Java Date#toString(), not ISO. Stored verbatim, like `eimsAckDate`. */
|
||||
export interface EimsCancelResponseBody {
|
||||
cancellationDate: string;
|
||||
}
|
||||
|
||||
export interface EimsCancelResponse {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
body?: EimsCancelResponseBody;
|
||||
}
|
||||
|
||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||
export interface EimsInvoiceError {
|
||||
kind: string;
|
||||
@@ -86,4 +110,10 @@ export interface EimsInvoiceStatusView {
|
||||
eimsSubmittedAt: Date | null;
|
||||
eimsAckDate: string | null;
|
||||
eimsLastError: EimsInvoiceError | null;
|
||||
/** Raw base64 PNG from MoR, already rendered on their side — see `Invoice.eimsSignedQr`. */
|
||||
eimsSignedQr: string | null;
|
||||
eimsCancelledAt: Date | null;
|
||||
eimsCancellationDate: string | null;
|
||||
eimsCancellationReasonCode: string | null;
|
||||
eimsCancellationRemark: string | null;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import { EimsSignedRequest } from "./eims.types";
|
||||
*
|
||||
* 1. compact `JSON.stringify` of the **inner** request object only,
|
||||
* 2. those exact UTF-8 bytes,
|
||||
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
|
||||
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding). Confirmed, not
|
||||
* assumed: MoR's own "Guide to Generating and Using Certificate for E-Invoicing" names
|
||||
* `SHA512withRSA` explicitly, which is PKCS#1v1.5 in Java (PSS would be named
|
||||
* `SHA512withRSAandMGF1`) — the same padding `createSign("RSA-SHA512")` uses by default.
|
||||
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
|
||||
* 5. base64 of the certificate file's exact bytes.
|
||||
* 5. base64 of the certificate file's exact bytes. Also confirmed by the same guide: its own
|
||||
* worked example certificate is the identical `Subject:`/`Issuer:` header + 3-cert PEM chain
|
||||
* text-file format ours is, base64'd with no re-encoding.
|
||||
*
|
||||
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
|
||||
* object is never mutated after serialization.
|
||||
|
||||
@@ -35,8 +35,14 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
||||
buyerCountryCode: null,
|
||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||
taxCodeByChargeType: {},
|
||||
taxRateByChargeType: {},
|
||||
exciseByChargeType: {},
|
||||
discountByChargeType: {},
|
||||
cashierName: null,
|
||||
salesPersonName: null,
|
||||
buyerIdType: null,
|
||||
buyerIdNumber: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
import { EimsAuthService } from "./eims-auth.service";
|
||||
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
|
||||
import { EimsCancellationService } from "./eims-cancellation.service";
|
||||
import { EimsClientService } from "./eims-client.service";
|
||||
import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||
import { EimsReceiptService } from "./eims-receipt.service";
|
||||
import { EimsSignerService } from "./eims-signer.service";
|
||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
|
||||
/**
|
||||
@@ -22,8 +26,9 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
|
||||
TypeOrmModule.forFeature([EimsSystemState, Invoice, EimsReceipt]),
|
||||
NotificationInboxModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [EimsInvoiceController],
|
||||
providers: [
|
||||
@@ -33,7 +38,15 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsAutoSubmitService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
],
|
||||
exports: [
|
||||
EimsAuthService,
|
||||
EimsClientService,
|
||||
EimsInvoiceRegistrationService,
|
||||
EimsCancellationService,
|
||||
EimsReceiptService,
|
||||
],
|
||||
exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService],
|
||||
})
|
||||
export class EimsModule {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -30,8 +30,11 @@ export class EimsSystemState extends BaseEntity {
|
||||
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
|
||||
inFlightDocumentNumber?: number | null;
|
||||
|
||||
/** IRN of the last successful registration; null until the first one succeeds. */
|
||||
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
|
||||
/**
|
||||
* IRN of the last successful registration; null until the first one succeeds. `text`, not a
|
||||
* fixed varchar — same reasoning as `Invoice.eimsIrn`: a real IRN already overflowed varchar(64).
|
||||
*/
|
||||
@Column({ name: "previous_irn", type: "text", nullable: true })
|
||||
previousIrn?: string | null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,10 @@ import { WsAuthService } from "./ws-auth.service";
|
||||
export class NotificationsGateway implements OnGatewayConnection {
|
||||
private readonly logger = new Logger(NotificationsGateway.name);
|
||||
|
||||
// Not `!`-asserted: Nest only wires this once the WS adapter attaches to a running HTTP
|
||||
// listener, which does not happen under `NestFactory.createApplicationContext` — see `skip()`.
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
private readonly server?: Server;
|
||||
|
||||
constructor(private readonly wsAuth: WsAuthService) {}
|
||||
|
||||
@@ -45,6 +47,7 @@ export class NotificationsGateway implements OnGatewayConnection {
|
||||
|
||||
/** Push a freshly-created notification + the new unread count to a user. */
|
||||
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
|
||||
if (!this.server) return this.skip("emitNew");
|
||||
const room = this.server.to(this.room(userId));
|
||||
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
|
||||
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
||||
@@ -52,11 +55,24 @@ export class NotificationsGateway implements OnGatewayConnection {
|
||||
|
||||
/** Push only an updated unread count (e.g. after a read on another tab). */
|
||||
emitUnreadCount(userId: string, unreadCount: number): void {
|
||||
if (!this.server) return this.skip("emitUnreadCount");
|
||||
this.server
|
||||
.to(this.room(userId))
|
||||
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* `@WebSocketServer()` only wires `server` once the WS adapter attaches to a running HTTP
|
||||
* listener — never under `NestFactory.createApplicationContext` (scripts, one-off jobs), and not
|
||||
* for the brief window before `app.listen()` completes in a real boot either. The notification row
|
||||
* is already persisted by this point (the caller writes it before pushing), so a missing socket
|
||||
* server just means "no live push this time" — skip it rather than throw and lose the caller's
|
||||
* own result (e.g. an EIMS registration outcome that already succeeded or failed for real).
|
||||
*/
|
||||
private skip(method: string): void {
|
||||
this.logger.debug(`${method}: no WebSocket server attached (non-HTTP context?) — push skipped`);
|
||||
}
|
||||
|
||||
private room(userId: string): string {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// otp.service.ts
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
@@ -317,6 +318,13 @@ export class OtpService {
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
|
||||
// Outcome only — the target is a phone number / email address and stays out
|
||||
// of the canonical line. Channels are safe and say which one was used.
|
||||
logCtx(
|
||||
{ mode, result, channels: channelsOf(target) },
|
||||
{ path: "otp.verify", mode: "push" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
@@ -111,6 +112,16 @@ export class PaymentClientService {
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
// Every hop to the payment service lands on the request log line: which
|
||||
// call, how slow, and what it answered. A settle that never happened is
|
||||
// almost always one of these coming back 4xx/5xx or timing out.
|
||||
const startedAt = Date.now();
|
||||
const trace = (extra: Record<string, unknown>) =>
|
||||
logCtx(
|
||||
{ method, path, ms: Date.now() - startedAt, ...extra },
|
||||
{ path: "outbound.payment", mode: "push" },
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
@@ -122,9 +133,11 @@ export class PaymentClientService {
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
trace({ status: response.status });
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
trace({ status: err.response.status });
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
@@ -136,6 +149,7 @@ export class PaymentClientService {
|
||||
}
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : String(err);
|
||||
trace({ unreachable: true, error: message });
|
||||
this.logger.error(
|
||||
`payment service unreachable (${method} ${path}): ${message}`,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
@@ -214,8 +215,16 @@ export class PaymentService {
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
referenceId,
|
||||
);
|
||||
logCtx(
|
||||
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
|
||||
{ path: "payment.reconcile" },
|
||||
);
|
||||
return { paid: result.paid, unverifiable: result.unverifiable };
|
||||
} catch (err) {
|
||||
logCtx(
|
||||
{ referenceId, unverifiable: true, error: (err as Error).message },
|
||||
{ path: "payment.reconcile" },
|
||||
);
|
||||
this.logger.warn(
|
||||
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
|
||||
);
|
||||
@@ -224,6 +233,18 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
logCtx(
|
||||
{
|
||||
referenceId: input.referenceId,
|
||||
source: input.source,
|
||||
orderRef: input.orderRef,
|
||||
method: input.method,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
platform: input.platform,
|
||||
},
|
||||
{ path: "payment.initiate" },
|
||||
);
|
||||
try {
|
||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||||
@@ -268,6 +289,17 @@ export class PaymentService {
|
||||
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
intentId: intent.id,
|
||||
providerStatus: snapshot.status,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
merchantOrderId: snapshot.merchantOrderId,
|
||||
immediateSuccess,
|
||||
},
|
||||
{ path: "payment.initiate" },
|
||||
);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
@@ -428,6 +460,17 @@ export class PaymentService {
|
||||
otp,
|
||||
);
|
||||
|
||||
logCtx(
|
||||
{
|
||||
intentId: local.id,
|
||||
refId: local.refId,
|
||||
gatewayIntentId: snapshot.intentId,
|
||||
providerStatus: confirmed.status,
|
||||
failureCode: confirmed.failureCode,
|
||||
},
|
||||
{ path: "payment.confirmOtp" },
|
||||
);
|
||||
|
||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: confirmed.providerTxnId,
|
||||
@@ -460,6 +503,16 @@ export class PaymentService {
|
||||
): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
logCtx(
|
||||
{
|
||||
intentId,
|
||||
refId: intent.refId,
|
||||
priorStatus: intent.status,
|
||||
providerTxnId: opts.providerTxnId,
|
||||
notifyBilling: opts.notify !== false,
|
||||
},
|
||||
{ path: "payment.settle" },
|
||||
);
|
||||
if (intent.status === "success") {
|
||||
// Still notify billing: a prior delivery may have flipped the intent to
|
||||
// success and then died before the invoice settled (the two steps are not
|
||||
@@ -471,6 +524,7 @@ export class PaymentService {
|
||||
opts.paidAt ?? intent.paidAt ?? undefined,
|
||||
);
|
||||
}
|
||||
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
|
||||
@@ -507,6 +561,15 @@ export class PaymentService {
|
||||
referenceId: string,
|
||||
): Promise<{ acknowledged: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||
logCtx(
|
||||
{
|
||||
referenceId,
|
||||
intentId: intent?.id,
|
||||
intentStatus: intent?.status ?? "none",
|
||||
method: intent?.method,
|
||||
},
|
||||
{ path: "payment.successRedirect" },
|
||||
);
|
||||
if (!intent || intent.method === "cbe-bill") {
|
||||
return { acknowledged: false };
|
||||
}
|
||||
@@ -533,6 +596,16 @@ export class PaymentService {
|
||||
}): Promise<void> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
logCtx(
|
||||
{
|
||||
intentId: intent.id,
|
||||
refId: intent.refId,
|
||||
priorStatus: intent.status,
|
||||
failureCode: input.failureCode,
|
||||
failureMessage: input.failureMessage,
|
||||
},
|
||||
{ path: "payment.failed" },
|
||||
);
|
||||
if (intent.status === "success" || intent.status === "canceled") return;
|
||||
|
||||
await this.paymentRepo.update(
|
||||
|
||||
@@ -70,6 +70,15 @@ export class CreateCargoTypeDto {
|
||||
@IsBoolean()
|
||||
hasLashing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasFuel?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
RATE_UNITS,
|
||||
} from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
@@ -94,6 +95,17 @@ export class CreateRateDto {
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||
baseLiters?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
|
||||
|
||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||
hasLashing!: boolean;
|
||||
|
||||
/**
|
||||
* Whether bookings of this cargo incur the fuel surcharge. Billed off the
|
||||
* lane-scoped FUEL rate for the booking's direction + route + this cargo
|
||||
* type (per liter or per wagon).
|
||||
*/
|
||||
@Column({ name: 'has_fuel', type: 'boolean', default: false })
|
||||
hasFuel!: boolean;
|
||||
|
||||
/**
|
||||
* Whether staff may write bulk contract templates against this cargo type.
|
||||
* Mutually exclusive between a parent group and its children: if the parent
|
||||
|
||||
@@ -46,6 +46,8 @@ export function deriveRateType(input: {
|
||||
return 'PIL_EXTRA_FEE';
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
return 'CUSTOMS_CLEARANCE';
|
||||
case 'FUEL':
|
||||
return 'FUEL_SURCHARGE';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ function unitsForShape(input: {
|
||||
case 'LASHING':
|
||||
// Bulk-only cargo securing — per ton or per wagon.
|
||||
return ['PER_TON', 'PER_WAGON'];
|
||||
case 'FUEL':
|
||||
// Per wagon (wagons × rate) or per liter (baseLiters × rate, once).
|
||||
return ['PER_WAGON', 'PER_LITER'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -24,6 +24,7 @@ export const RATE_TYPES = [
|
||||
'RETURN_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'FUEL_SURCHARGE',
|
||||
] as const;
|
||||
|
||||
export type RateType = typeof RATE_TYPES[number];
|
||||
@@ -41,6 +42,8 @@ export const RATE_UNITS = [
|
||||
'PER_KM',
|
||||
// Last-mile bulk: price = tons × km × rateValue.
|
||||
'PER_TON_KM',
|
||||
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
|
||||
'PER_LITER',
|
||||
'PER_INVOICE',
|
||||
'FLAT',
|
||||
] as const;
|
||||
@@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [
|
||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||
'CUSTOMS_CLEARANCE',
|
||||
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||
// billed off the lane-scoped rate (direction + route + cargo type).
|
||||
'FUEL',
|
||||
] as const;
|
||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
|
||||
@@ -163,6 +169,14 @@ export class Rate extends BaseEntity {
|
||||
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
||||
* open-ended). NULL on every other rate shape.
|
||||
*/
|
||||
/**
|
||||
* FUEL rates billed PER_LITER only: the liters the surcharge covers —
|
||||
* price = baseLiters × rateValue, once per booking. NULL on every other
|
||||
* rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead).
|
||||
*/
|
||||
@Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||||
baseLiters?: number | null;
|
||||
|
||||
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
minKm?: number | null;
|
||||
|
||||
|
||||
@@ -422,3 +422,108 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)',
|
||||
expect(lashingMods(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
|
||||
const fuelPerLiter: Rate = {
|
||||
id: 'rate-fuel-liter',
|
||||
rateType: 'FUEL_SURCHARGE',
|
||||
trigger: 'FUEL',
|
||||
rateValue: 2,
|
||||
rateUnit: 'PER_LITER',
|
||||
baseLiters: 100,
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: 'cargo-steel',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-nagad',
|
||||
destinationYardId: 'yard-mojo',
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[], hasFuel = true): RuleEngineService =>
|
||||
new RuleEngineService(
|
||||
{
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const fuelInput = (
|
||||
overrides: Partial<BookingEvaluationInput> = {},
|
||||
): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
cargoTypeId: 'cargo-steel',
|
||||
originYardId: 'yard-nagad',
|
||||
destinationYardId: 'yard-mojo',
|
||||
totalWagons: 0,
|
||||
bulkTons: 100,
|
||||
bulkWagons: 4,
|
||||
containers: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
|
||||
|
||||
it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => {
|
||||
const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
|
||||
const mods = fuelMods(result);
|
||||
expect(mods).toHaveLength(1);
|
||||
// Flat: the customer sees only the total, and a frozen contract snapshot
|
||||
// (also stored flat) multiplies it by quantity 1 — never by the liters.
|
||||
expect(mods[0].triggerValue).toBe(1);
|
||||
expect(mods[0].unitPriceUsd).toBe(200);
|
||||
expect(mods[0].calculatedAmount).toBe(200);
|
||||
expect(mods[0].billingUnit).toBe('FLAT');
|
||||
});
|
||||
|
||||
it('PER_WAGON bills the wagons the cargo occupies', async () => {
|
||||
const result = await buildService([
|
||||
{ ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate,
|
||||
]).evaluate(fuelInput());
|
||||
const mods = fuelMods(result);
|
||||
expect(mods[0].triggerValue).toBe(4);
|
||||
expect(mods[0].calculatedAmount).toBe(200);
|
||||
});
|
||||
|
||||
it('a rate for another lane, direction or commodity never bills', async () => {
|
||||
for (const wrong of [
|
||||
{ tradeDirection: 'EXPORT' },
|
||||
{ originYardId: 'yard-other' },
|
||||
{ destinationYardId: 'yard-other' },
|
||||
{ cargoTypeId: 'cargo-wheat' },
|
||||
]) {
|
||||
const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate(
|
||||
fuelInput(),
|
||||
);
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('a domestic booking bills the DOMESTIC fuel lane', async () => {
|
||||
const result = await buildService([
|
||||
{ ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate,
|
||||
]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' }));
|
||||
expect(fuelMods(result)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('no fuel charge when the cargo type does not have hasFuel', async () => {
|
||||
const result = await buildService([fuelPerLiter], false).evaluate(fuelInput());
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('no matching lane rate bills nothing (lenient, like lashing)', async () => {
|
||||
const result = await buildService([]).evaluate(fuelInput());
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,6 +175,9 @@ export class RuleEngineService {
|
||||
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
|
||||
// input flag when no cargo type is set (e.g. container bookings).
|
||||
let hasLashing = input.hasLashing === true;
|
||||
// Fuel is likewise a cargo-type property (hasFuel), billed off the
|
||||
// lane-scoped FUEL rate — see fuelCharges.
|
||||
let hasFuel = false;
|
||||
if (input.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
@@ -186,6 +189,9 @@ export class RuleEngineService {
|
||||
if (cargoType.hasLashing) {
|
||||
hasLashing = true;
|
||||
}
|
||||
if (cargoType.hasFuel) {
|
||||
hasFuel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +334,9 @@ export class RuleEngineService {
|
||||
// Lashing is sold per cargo kind + container type — billed by the
|
||||
// kind-aware block below, never by this generic loop.
|
||||
if (rate.trigger === 'LASHING') continue;
|
||||
// Fuel is sold per lane + cargo type — billed by the route-matched
|
||||
// block below, never by this route-agnostic loop.
|
||||
if (rate.trigger === 'FUEL') continue;
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
@@ -442,6 +451,10 @@ export class RuleEngineService {
|
||||
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||
}
|
||||
|
||||
if (hasFuel) {
|
||||
appliedModifiers.push(...this.fuelCharges(input, liveRates));
|
||||
}
|
||||
|
||||
return {
|
||||
priorityScore,
|
||||
appliedModifiers,
|
||||
@@ -632,6 +645,56 @@ export class RuleEngineService {
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||
* billed off the FUEL rate matching the booking's lane (trade direction +
|
||||
* origin + destination) and cargo type. PER_LITER collapses to one FLAT
|
||||
* amount (baseLiters × rateValue, once per booking) — the customer only ever
|
||||
* sees the total, and the frozen contract snapshot stores that same flat
|
||||
* figure so the snapshot-override math bills it exactly once. PER_WAGON
|
||||
* bills the wagons the cargo occupies. No matching lane rate simply bills
|
||||
* nothing — same leniency as lashing.
|
||||
*/
|
||||
private fuelCharges(
|
||||
input: BookingEvaluationInput,
|
||||
liveRates: Rate[],
|
||||
): AppliedCargoModifier[] {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
const rate = liveRates.find(
|
||||
(r) =>
|
||||
r.trigger === 'FUEL' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === input.tradeDirection &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId &&
|
||||
r.cargoTypeId === input.cargoTypeId,
|
||||
);
|
||||
if (!rate) return modifiers;
|
||||
|
||||
const rateValue = Number(rate.rateValue);
|
||||
const wagons = Math.max(
|
||||
0,
|
||||
Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0),
|
||||
);
|
||||
const perLiter = rate.rateUnit === 'PER_LITER';
|
||||
const billedQty = perLiter ? 1 : wagons;
|
||||
const unitPrice = perLiter
|
||||
? Number(rate.baseLiters ?? 0) * rateValue
|
||||
: rateValue;
|
||||
const amount = billedQty * unitPrice;
|
||||
if (!(amount > 0)) return modifiers;
|
||||
modifiers.push({
|
||||
rateId: rate.id,
|
||||
surchargeCode: this.surchargeCode(rate),
|
||||
triggerValue: billedQty,
|
||||
calculatedAmount: amount,
|
||||
currency: rate.currency,
|
||||
unitPriceUsd: unitPrice,
|
||||
billingUnit: perLiter ? 'FLAT' : rate.rateUnit,
|
||||
});
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages for container lines whose total weight exceeds the hard capacity
|
||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||
|
||||
@@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => {
|
||||
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
|
||||
});
|
||||
|
||||
it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => {
|
||||
const { service } = build({
|
||||
rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }),
|
||||
});
|
||||
|
||||
const request = await service.submit({
|
||||
rateId: 'rate-1',
|
||||
update: { rateUnit: 'PER_LITER', baseLiters: 3 },
|
||||
});
|
||||
|
||||
expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 });
|
||||
});
|
||||
|
||||
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
|
||||
@@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [
|
||||
// LIVE last-mile rate would diff to "nothing changed".
|
||||
'minKm',
|
||||
'maxKm',
|
||||
// PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER
|
||||
// dropped the submitted liters and validation failed with "needs a base
|
||||
// liters amount" even though the payload carried one.
|
||||
'baseLiters',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,15 +121,16 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance
|
||||
* and empty-container return are the surcharges that are too — their fee
|
||||
* depends on the lane (and, for returns, the container type).
|
||||
* Rates sold per direction + route. Base freight always; customs clearance,
|
||||
* empty-container return and fuel are the surcharges that are too — their
|
||||
* fee depends on the lane (and, for returns, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN'
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'FUEL'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +161,9 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
tradeDirection: string | null,
|
||||
): { origin: YardCountry; destination: YardCountry } {
|
||||
if (appliesTo === 'INTERCITY') {
|
||||
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
||||
// inside Ethiopia exactly like intercity base freight.
|
||||
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||
}
|
||||
return tradeDirection === 'EXPORT'
|
||||
@@ -291,6 +294,31 @@ export class RatesService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trigger === 'FUEL') {
|
||||
// Fuel is sold per lane + commodity: the direction says which countries
|
||||
// the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo
|
||||
// type names the commodity — different commodities price differently.
|
||||
if (
|
||||
tradeDirection !== 'IMPORT' &&
|
||||
tradeDirection !== 'EXPORT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||||
);
|
||||
}
|
||||
if (containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A fuel rate cannot be scoped to a container type.',
|
||||
);
|
||||
}
|
||||
if (!cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A fuel rate must name the cargo type it covers.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trigger === 'WITH_RETURN') {
|
||||
// Returning the empty box only exists on imports (the box goes back to
|
||||
// the port) — export return rates are rejected until the business sells
|
||||
@@ -502,15 +530,21 @@ export class RatesService {
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId =
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING'
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? (dto.cargoTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.cargoTypeId ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs.
|
||||
// its yard pair already says where it runs. (Fuel is the exception: its
|
||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||
// nothing about the direction.)
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -563,6 +597,8 @@ export class RatesService {
|
||||
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||||
}
|
||||
|
||||
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
@@ -588,6 +624,7 @@ export class RatesService {
|
||||
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit,
|
||||
baseLiters,
|
||||
minKm,
|
||||
maxKm,
|
||||
status: 'DRAFT',
|
||||
@@ -595,6 +632,25 @@ export class RatesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue,
|
||||
* once per booking). Required there; cleared on every other rate shape —
|
||||
* a PER_WAGON fuel rate bills wagons × rateValue and carries none.
|
||||
*/
|
||||
private resolveBaseLiters(
|
||||
rateUnit: Rate['rateUnit'],
|
||||
baseLiters?: number | null,
|
||||
): number | null {
|
||||
if (rateUnit !== 'PER_LITER') return null;
|
||||
const liters = Number(baseLiters);
|
||||
if (!(liters > 0)) {
|
||||
throw new BadRequestException(
|
||||
'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.',
|
||||
);
|
||||
}
|
||||
return liters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||||
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||||
@@ -680,14 +736,18 @@ export class RatesService {
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING';
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL';
|
||||
const cargoTypeId = !keepsCargoType
|
||||
? null
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection
|
||||
@@ -788,6 +848,11 @@ export class RatesService {
|
||||
ignoreId: id,
|
||||
});
|
||||
|
||||
updates.baseLiters = this.resolveBaseLiters(
|
||||
rateUnit,
|
||||
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
|
||||
);
|
||||
|
||||
updates.currency =
|
||||
appliesTo === 'LAST_MILE'
|
||||
? (dto.currency ?? existing.currency ?? 'ETB')
|
||||
|
||||
@@ -15,21 +15,21 @@ export class StampSettingsController {
|
||||
constructor(private readonly service: StampSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin])
|
||||
@BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
|
||||
get() {
|
||||
return this.service.getView();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the company stamp" })
|
||||
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
|
||||
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({
|
||||
summary: "Clear the company stamp (invoices fall back to the plain seal)",
|
||||
})
|
||||
|
||||
@@ -129,6 +129,7 @@ import {
|
||||
perEdgeConsistUsage,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
type ContainerPlacementInput,
|
||||
type WagonPlanSlot,
|
||||
} from '../utils/wagon-plan.util';
|
||||
@@ -330,6 +331,8 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
interface BookingWindowRow {
|
||||
schedule_id: string;
|
||||
reference: string | null;
|
||||
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
|
||||
train_number: string | null;
|
||||
contract_id: string | null;
|
||||
contract_kind: string | null;
|
||||
direction: string | null;
|
||||
@@ -2928,6 +2931,10 @@ export class TrainSchedulingService {
|
||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.size) {
|
||||
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
|
||||
// is confirmed — allocation time already enforces it, but a wagon swap or
|
||||
// an edited allocation since then could have broken it unnoticed.
|
||||
await this.assertWagonContainerCapacity(scheduleId);
|
||||
// Export cargo must be received at the warehouse with a GRN before it can
|
||||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||
if (this.isExportSchedule(schedule)) {
|
||||
@@ -3395,6 +3402,72 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
|
||||
* time. `validateContainerPlacements` already enforces this the moment a
|
||||
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
|
||||
* wagon swap, an edited allocation, or a container item added out-of-band
|
||||
* between allocation and loading could still leave a wagon over its 2-TEU
|
||||
* capacity undetected until the train is already loaded. This closes that gap
|
||||
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
|
||||
* same size resolution as the marshalling document) right before loading is
|
||||
* confirmed.
|
||||
*/
|
||||
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
|
||||
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
pw.wagon_number AS "wagonNumber",
|
||||
SUM(
|
||||
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||||
WHEN 40 THEN 2
|
||||
WHEN 20 THEN 1
|
||||
ELSE
|
||||
CASE
|
||||
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||||
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
END
|
||||
) AS "teuUsed"
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||
JOIN freight.wagon_allocation_container_items wci
|
||||
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
|
||||
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
|
||||
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
|
||||
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
|
||||
WHERE tsw.train_set_id = (
|
||||
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
|
||||
)
|
||||
AND tsw.deleted_at IS NULL
|
||||
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
|
||||
HAVING SUM(
|
||||
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||||
WHEN 40 THEN 2
|
||||
WHEN 20 THEN 1
|
||||
ELSE
|
||||
CASE
|
||||
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||||
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
END
|
||||
) > $2`,
|
||||
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
|
||||
);
|
||||
if (rows.length) {
|
||||
const labels = rows
|
||||
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -5872,6 +5945,7 @@ export class TrainSchedulingService {
|
||||
wagonSlots: schedule.trainSet?.wagons,
|
||||
storedWagonCount: schedule.trainSet?.wagonCount,
|
||||
scheduleBookings: schedule.scheduleBookings,
|
||||
maxWagons: schedule.maxWagons,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -6847,6 +6921,7 @@ export class TrainSchedulingService {
|
||||
`SELECT DISTINCT ON (ts.id)
|
||||
ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
ts.train_number,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
@@ -6903,6 +6978,7 @@ export class TrainSchedulingService {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
ts.train_number,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
@@ -6948,9 +7024,7 @@ export class TrainSchedulingService {
|
||||
*/
|
||||
async listAllBookingWindows() {
|
||||
const rows: Array<
|
||||
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
|
||||
train_number: string | null;
|
||||
}
|
||||
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
@@ -6980,14 +7054,13 @@ export class TrainSchedulingService {
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
...this.mapBookingWindowRow({
|
||||
return rows.map((r) =>
|
||||
this.mapBookingWindowRow({
|
||||
...r,
|
||||
contract_id: null,
|
||||
contract_kind: null,
|
||||
}),
|
||||
trainNumber: r.train_number,
|
||||
}));
|
||||
);
|
||||
}
|
||||
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
@@ -7005,6 +7078,7 @@ export class TrainSchedulingService {
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
reference: r.reference ?? null,
|
||||
trainNumber: r.train_number ?? null,
|
||||
contractId: r.contract_id,
|
||||
contractKind: r.contract_kind,
|
||||
direction: r.direction,
|
||||
@@ -9301,9 +9375,14 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
// Every schedule the target train is committed to, via its train sets.
|
||||
// Locomotives come along: the merged train is pulled by the union of this
|
||||
// schedule's locos and the target train's, so capacity checks need both.
|
||||
const targetSets = await this.dataSource
|
||||
.getRepository(TrainSet)
|
||||
.find({ where: { trainId: targetTrainId } });
|
||||
.find({
|
||||
where: { trainId: targetTrainId },
|
||||
relations: { locomotives: { locomotive: true }, locomotive: true },
|
||||
});
|
||||
const targetSetIds = targetSets.map((s) => s.id);
|
||||
const targetSchedules = targetSetIds.length
|
||||
? await this.dataSource.getRepository(TrainSchedule).find({
|
||||
@@ -9346,6 +9425,24 @@ export class TrainSchedulingService {
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
|
||||
|
||||
// EVERY wagon physically on the source train moves with the merge — not
|
||||
// just the ones coupled into this schedule's set. A wagon left behind
|
||||
// would strand on the deactivated train. "Loose" = on the train but not
|
||||
// backing a set slot; it joins the counts and the capacity math.
|
||||
const sourceWagons = sourceTrainId
|
||||
? await this.dataSource
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
|
||||
: [];
|
||||
const coupledPhysicalIds = new Set(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.map((w) => w.physicalWagonId)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const looseSourceWagons = sourceWagons.filter(
|
||||
(w) => !coupledPhysicalIds.has(w.id),
|
||||
);
|
||||
|
||||
const movingBookings = absorbed
|
||||
? await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||
where: { trainScheduleId: absorbed.id },
|
||||
@@ -9357,10 +9454,12 @@ export class TrainSchedulingService {
|
||||
schedule,
|
||||
sourceTrainId,
|
||||
targetTrain,
|
||||
targetSets,
|
||||
absorbed,
|
||||
affectedOthers,
|
||||
untouched,
|
||||
incomingWagons,
|
||||
looseSourceWagons,
|
||||
movingBookings,
|
||||
};
|
||||
}
|
||||
@@ -9382,14 +9481,20 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capacity: the merged consist must fit this schedule's locomotives ────
|
||||
// ── Capacity: the merged consist must fit the merged train's locomotives ─
|
||||
// Existing side = coupled set slots PLUS loose wagons riding the source
|
||||
// train without a slot — they all move, so they all count.
|
||||
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
|
||||
lengthMeters: Number(w.lengthMeters) || 0,
|
||||
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
|
||||
cargoTons: 0,
|
||||
}));
|
||||
const wagonTypeIds = [
|
||||
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
|
||||
...new Set(
|
||||
[...incomingWagons, ...plan.looseSourceWagons]
|
||||
.map((w) => w.wagonTypeId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const wagonTypes = wagonTypeIds.length
|
||||
? await this.dataSource
|
||||
@@ -9397,16 +9502,27 @@ export class TrainSchedulingService {
|
||||
.find({ where: { id: In(wagonTypeIds) } })
|
||||
: [];
|
||||
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
|
||||
const incomingSlots = incomingWagons.map((w) => {
|
||||
const slotFromWagon = (w: Wagon) => {
|
||||
const t = typeById.get(w.wagonTypeId);
|
||||
return {
|
||||
lengthMeters: Number(t?.lengthMeters) || 0,
|
||||
tareWeightTons: Number(t?.tareWeightTons) || 0,
|
||||
cargoTons: 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
const incomingSlots = incomingWagons.map(slotFromWagon);
|
||||
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
|
||||
|
||||
const limits = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
// The merged train is pulled by the union of this schedule's locomotives
|
||||
// and whatever already pulls the target train (its sets keep their locos).
|
||||
// Pull weight adds up across the pool; length stays the tightest cap.
|
||||
const locoPool = [
|
||||
...this.locomotivesOfTrainSet(schedule.trainSet),
|
||||
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
|
||||
];
|
||||
const limits = combinedLocomotiveLimits([
|
||||
...new Map(locoPool.map((l) => [l.id, l])).values(),
|
||||
]);
|
||||
if (limits) {
|
||||
const rules = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
@@ -9415,13 +9531,17 @@ export class TrainSchedulingService {
|
||||
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
|
||||
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
|
||||
});
|
||||
const merged = [...existingSlots, ...incomingSlots];
|
||||
// maxWagons is the schedule's own slot ceiling; fall back to the consist
|
||||
// size when it is unset so the count axis never blocks spuriously.
|
||||
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
|
||||
// Merge is a physical consist move, so only the physical axes gate it:
|
||||
// can this schedule's locomotives pull the merged weight and length.
|
||||
// `schedule.maxWagons` is the booking-window planning ceiling — using it
|
||||
// as a slot cap here blocked every merge into a bigger train (e.g. a
|
||||
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
|
||||
// ceiling to the merged size instead.
|
||||
const violations = consistViolations(merged, {
|
||||
maxWeightTons: caps.maxWeightTons,
|
||||
maxLengthMeters: caps.maxLengthMeters,
|
||||
maxWagonSlots: schedule.maxWagons || merged.length,
|
||||
maxWagonSlots: merged.length,
|
||||
});
|
||||
blockers.push(...violations);
|
||||
}
|
||||
@@ -9481,7 +9601,10 @@ export class TrainSchedulingService {
|
||||
const plan = await this.planMerge(scheduleId, targetTrainId);
|
||||
const blockers = await this.mergeBlockers(plan);
|
||||
|
||||
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
|
||||
// Coupled slots plus loose wagons on the source train — everything moves.
|
||||
const existingCount =
|
||||
(plan.schedule.trainSet?.wagons?.length ?? 0) +
|
||||
plan.looseSourceWagons.length;
|
||||
return {
|
||||
canMerge: blockers.length === 0,
|
||||
blockers,
|
||||
@@ -9555,13 +9678,20 @@ export class TrainSchedulingService {
|
||||
trainId: targetTrain.id,
|
||||
});
|
||||
|
||||
// 2. The physical wagons follow the train.
|
||||
// 2. The physical wagons follow the train — the target's stay put, and
|
||||
// EVERY wagon on the source train (coupled or loose) moves across so
|
||||
// nothing strands on the deactivated train.
|
||||
if (incomingWagons.length) {
|
||||
await manager.getRepository(Wagon).update(
|
||||
{ id: In(incomingWagons.map((w) => w.id)) },
|
||||
{ trainId: targetTrain.id },
|
||||
);
|
||||
}
|
||||
if (sourceTrainId) {
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
|
||||
}
|
||||
|
||||
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
||||
// after the existing wagons. Sequence is provisional — staff reorder
|
||||
@@ -9611,6 +9741,14 @@ export class TrainSchedulingService {
|
||||
await manager
|
||||
.getRepository(TrainSet)
|
||||
.update(trainSetId, { wagonCount: mergedCount });
|
||||
|
||||
// 8. Booking capacity follows the consist: raise (never lower) the
|
||||
// planning ceiling so the merged wagons are actually sellable.
|
||||
if (mergedCount > (schedule.maxWagons ?? 0)) {
|
||||
await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.update(schedule.id, { maxWagons: mergedCount });
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
|
||||
@@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => {
|
||||
expect(usage.wagonsRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it('sells against the planned ceiling, not the partially coupled consist', () => {
|
||||
// S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking
|
||||
// that reserved 2 — the list read "2/2 used, 0 bookable" while the detail
|
||||
// page and the booking gate (remainingWagonsForLeg vs maxWagons) both said
|
||||
// 1 wagon was still free. Wagons couple on demand; the ceiling is capacity.
|
||||
const usage = computeScheduleWagonUsage({
|
||||
wagonSlots: Array(2).fill(slot(true)),
|
||||
storedWagonCount: 2,
|
||||
scheduleBookings: [booking(2)],
|
||||
maxWagons: 3,
|
||||
});
|
||||
|
||||
expect(usage.wagonsUsed).toBe(2);
|
||||
expect(usage.wagonsTotal).toBe(3);
|
||||
expect(usage.wagonsRemaining).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to the stored counter when slot rows were not loaded', () => {
|
||||
const usage = computeScheduleWagonUsage({
|
||||
wagonSlots: [],
|
||||
|
||||
@@ -20,11 +20,11 @@ export interface ScheduleBookingLike {
|
||||
export interface ScheduleWagonUsage {
|
||||
/** Coupled slots carrying at least one booking allocation. */
|
||||
wagonsUsed: number;
|
||||
/** Coupled consist size — the denominator of `wagonsUsed`. */
|
||||
/** Schedule capacity — the larger of coupled consist and planned ceiling. */
|
||||
wagonsTotal: number;
|
||||
/** Wagons claimed by bookings, including bookings that have not paid. */
|
||||
wagonsReserved: number;
|
||||
/** Consist minus what bookings have claimed — what is still bookable. */
|
||||
/** Capacity minus what bookings have claimed — what is still bookable. */
|
||||
wagonsRemaining: number;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: {
|
||||
/** Stored counter; used only when the slot rows were not loaded. */
|
||||
storedWagonCount?: number | null;
|
||||
scheduleBookings?: ScheduleBookingLike[] | null;
|
||||
/** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */
|
||||
maxWagons?: number | null;
|
||||
}): ScheduleWagonUsage {
|
||||
const slots = input.wagonSlots ?? [];
|
||||
|
||||
@@ -42,7 +44,14 @@ export function computeScheduleWagonUsage(input: {
|
||||
|
||||
// Prefer live slot rows; the stored counter drifts when a consist is edited
|
||||
// without a recompute, which is why the list and detail disagreed on totals.
|
||||
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
|
||||
const coupled = slots.length || (input.storedWagonCount ?? 0);
|
||||
|
||||
// Wagons are coupled on demand as bookings are allocated, so a partially
|
||||
// built consist does not cap what is bookable — the planned ceiling does
|
||||
// (remainingWagonsForLeg sells against maxWagons). Without this, a schedule
|
||||
// planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable"
|
||||
// while its detail page and the booking gate both said 1 wagon was free.
|
||||
const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0);
|
||||
|
||||
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
|
||||
const wagonsReserved = (input.scheduleBookings ?? []).reduce(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
|
||||
|
||||
@@ -165,10 +166,13 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
* actually produce is a backoffice decision, edited in the file-settings editor.
|
||||
*/
|
||||
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
// First on purpose: only the first field of a new set is seeded, and this is
|
||||
// the paper that distinguishes a co-operative from every other company.
|
||||
{
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
fileKey: "cooperative_registration_certificate",
|
||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||
helpText:
|
||||
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
@@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
fileKey: "cooperative_registration_certificate",
|
||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||
helpText:
|
||||
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
@@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder {
|
||||
async run() {
|
||||
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
|
||||
|
||||
// Seed only into an empty table: any existing rows (including
|
||||
// soft-deleted ones, which would still conflict on the unique `code`)
|
||||
// mean the data is admin-managed, so leave it untouched.
|
||||
const existing = await settingRepository.count({ withDeleted: true });
|
||||
if (existing > 0) {
|
||||
this.logger.log(
|
||||
`file_upload_settings already has ${existing} rows — skipping seed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Seed per CODE, not "only into an empty table". An existing row is
|
||||
// admin-managed and never touched — including a soft-deleted one, which
|
||||
// means the set was removed on purpose (and would still conflict on the
|
||||
// unique `code`). What the table-wide check got wrong is the other half: a
|
||||
// set added to this file after the first boot could never reach a database
|
||||
// that already held the others, so it existed in code and nowhere else.
|
||||
const existingCodes = new Set(
|
||||
(await settingRepository.find({ withDeleted: true })).map((s) => s.code),
|
||||
);
|
||||
|
||||
const allSettings: Array<
|
||||
OnboardingDocumentSetting & { description: string }
|
||||
@@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder {
|
||||
})),
|
||||
];
|
||||
|
||||
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
||||
// and are configured from the backoffice file-settings editor; the field
|
||||
// definitions above are kept as reference defaults.
|
||||
await settingRepository.insert(
|
||||
allSettings.map((documentSetting) => ({
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
})),
|
||||
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
|
||||
if (missing.length === 0) {
|
||||
this.logger.log("file upload settings up to date — nothing to seed");
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = await settingRepository.save(
|
||||
missing.map((documentSetting) =>
|
||||
settingRepository.create({
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// A brand-new set gets exactly ONE field: its first reference default. The
|
||||
// rest of the list above stays documentation — what a set actually asks for
|
||||
// is a backoffice decision, edited in the file-settings editor. Seeding one
|
||||
// means a set is never born empty (an empty set silently requires nothing),
|
||||
// while leaving the admin a single row to extend rather than a list to prune.
|
||||
const fieldRepository = this.dataSource.getRepository(FileUploadField);
|
||||
const firstFields = inserted.flatMap((setting) => {
|
||||
const reference = missing.find((s) => s.code === setting.code)?.fields[0];
|
||||
return reference
|
||||
? [fieldRepository.create({ ...reference, settingId: setting.id })]
|
||||
: [];
|
||||
});
|
||||
if (firstFields.length > 0) await fieldRepository.save(firstFields);
|
||||
|
||||
this.logger.log(
|
||||
`Seeded ${allSettings.length} file upload settings with empty fields`,
|
||||
`Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing
|
||||
.map((s) => s.code)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +482,20 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:eims_resolve",
|
||||
"Resolve a blocked MoR EIMS submission",
|
||||
),
|
||||
// Cancellation is a separate irreversible-at-MoR action from registration — its own grant,
|
||||
// same reasoning as eims_register.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-000000000008",
|
||||
"edr_freight_app:invoices:eims_cancel",
|
||||
"Cancel a registered invoice with MoR EIMS",
|
||||
),
|
||||
// Covers both sales and withholding receipts — same risk profile (filing a document with
|
||||
// MoR), no reason to split further.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-000000000009",
|
||||
"edr_freight_app:invoices:eims_receipt_register",
|
||||
"Register a sales or withholding receipt with MoR EIMS",
|
||||
),
|
||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||
perm(
|
||||
@@ -1190,22 +1204,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"b4b00002-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:stamp:view",
|
||||
"View stamp settings",
|
||||
"View the company stamp",
|
||||
),
|
||||
perm(
|
||||
"b4b00002-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:stamp:manage",
|
||||
"Manage stamp settings",
|
||||
"Manage the company stamp",
|
||||
),
|
||||
// The per-officer approval teeter (ማህተም) — an individual's own stamp +
|
||||
// signature, not the company seal. It used to ride on settings:stamp:*, which
|
||||
// now gates the ONE company stamp; this key was split out when the two were
|
||||
// untangled. `settings:invoice_stamp:*` retired at the same time: it gated the
|
||||
// company stamp before the fold and is deliberately left orphaned in any DB
|
||||
// that already seeded it (the seeder upserts by key and never deletes).
|
||||
perm(
|
||||
"b4b00003-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:settings:invoice_stamp:view",
|
||||
"View invoice stamp settings",
|
||||
"edr_freight_app:settings:teeter:view",
|
||||
"View own approval teeter and signature",
|
||||
),
|
||||
perm(
|
||||
"b4b00003-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:settings:invoice_stamp:manage",
|
||||
"Manage invoice stamp settings",
|
||||
"edr_freight_app:settings:teeter:manage",
|
||||
"Manage own approval teeter and signature",
|
||||
),
|
||||
perm(
|
||||
"b4c00001-0001-4000-8000-000000000001",
|
||||
@@ -1701,6 +1721,8 @@ export const FREIGHT_PERMS = {
|
||||
export: "edr_freight_app:invoices:export",
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
eimsCancel: "edr_freight_app:invoices:eims_cancel",
|
||||
eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
},
|
||||
firstMile: {
|
||||
@@ -1907,15 +1929,18 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:dropdown:view",
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
// The ONE company stamp/seal, applied to every generated document
|
||||
// (invoices, receipts, warehouse papers, the EDR side of contracts).
|
||||
stamp: {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
||||
invoiceStamp: {
|
||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
||||
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||
// gate the company stamp before the two were untangled.
|
||||
teeter: {
|
||||
view: "edr_freight_app:settings:teeter:view",
|
||||
manage: "edr_freight_app:settings:teeter:manage",
|
||||
},
|
||||
exchangeRate: {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
@@ -2216,10 +2241,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
// Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve.
|
||||
// Invoices are filed with MoR by the workflow, not by a person, so filing is not a
|
||||
// Finance job function — the endpoints exist for controlled testing and exceptional
|
||||
// operations, and are assigned to named admins rather than a role preset.
|
||||
// Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel,
|
||||
// eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so
|
||||
// filing is not a Finance job function — the endpoints exist for controlled testing and
|
||||
// exceptional operations, and are assigned to named admins rather than a role preset.
|
||||
FREIGHT_PERMS.payments.view,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
],
|
||||
|
||||
@@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
|
||||
import NoAccessPage from "./pages/NoAccessPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
|
||||
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
|
||||
import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
@@ -786,23 +785,27 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
The ONE company stamp, for every generated document. The per-officer
|
||||
teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings
|
||||
now lives at /user-management/teeter-and-signature — it is a different
|
||||
thing (an individual's approval stamp), and pairing the two here was
|
||||
the duplication.
|
||||
*/}
|
||||
<Route
|
||||
path="stamp-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
|
||||
<StampSettings />
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.stamp.view}
|
||||
>
|
||||
<CompanyStampSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
|
||||
<Route
|
||||
path="invoice-stamp-settings"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
|
||||
>
|
||||
<InvoiceStampSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
element={<Navigate to="/dashboard/stamp-settings" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates"
|
||||
|
||||
@@ -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;
|
||||
@@ -24,4 +24,9 @@ export {
|
||||
type ResetPasswordActionProps,
|
||||
} from "./ResetPasswordAction";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export {
|
||||
PersonCard,
|
||||
type PersonCardProps,
|
||||
type PersonField,
|
||||
} from "./PersonCard";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PackageOpen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
Stamp,
|
||||
ScrollText,
|
||||
Send,
|
||||
Settings,
|
||||
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
permission: FREIGHT_PERMS.settings.dropdown.view,
|
||||
},
|
||||
{
|
||||
label: "Stamp settings",
|
||||
// One entry, one stamp. The former "Stamp settings" entry here pointed
|
||||
// at the per-officer teeter (ማህተም), not a company seal — it moved to
|
||||
// /user-management/teeter-and-signature.
|
||||
label: "Company stamp",
|
||||
href: "/dashboard/stamp-settings",
|
||||
icon: <FileSignature />,
|
||||
icon: <Stamp />,
|
||||
permission: FREIGHT_PERMS.settings.stamp.view,
|
||||
},
|
||||
{
|
||||
label: "Invoice stamp",
|
||||
href: "/dashboard/invoice-stamp-settings",
|
||||
icon: <Receipt />,
|
||||
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
|
||||
},
|
||||
{
|
||||
label: "Contract templates",
|
||||
href: "/dashboard/contract-templates",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -319,7 +319,7 @@ export const TopBar = () => {
|
||||
{t("header.viewProfile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/change-password")}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
|
||||
<Key className="w-4 h-4 mr-3" />
|
||||
{t("header.changePassword")}
|
||||
|
||||
@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:dropdown:view",
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
// The ONE company stamp/seal, applied to every generated document
|
||||
// (invoices, receipts, warehouse papers, the EDR side of contracts).
|
||||
stamp: {
|
||||
view: "edr_freight_app:settings:stamp:view",
|
||||
manage: "edr_freight_app:settings:stamp:manage",
|
||||
},
|
||||
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
|
||||
// from `stamp` above, which is the per-employee approval-record teeter.
|
||||
invoiceStamp: {
|
||||
view: "edr_freight_app:settings:invoice_stamp:view",
|
||||
manage: "edr_freight_app:settings:invoice_stamp:manage",
|
||||
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
|
||||
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
|
||||
// gate the company stamp before the two were untangled.
|
||||
teeter: {
|
||||
view: "edr_freight_app:settings:teeter:view",
|
||||
manage: "edr_freight_app:settings:teeter:manage",
|
||||
},
|
||||
exchangeRate: {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
|
||||
@@ -502,7 +502,7 @@ const Header = () => {
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
|
||||
onClick={() => navigate("/change-password")}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
>
|
||||
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
|
||||
<span className="font-medium">
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
} from "@/hooks/contract-templates/useContractTemplates";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { cargoTypesService } from "@/services/cargo-types.service";
|
||||
import type { ContractTemplate } from "@/services/contract-templates.service";
|
||||
import type {
|
||||
BulkTemplateDirection,
|
||||
ContractTemplate,
|
||||
} from "@/services/contract-templates.service";
|
||||
import TemplatePreviewModal from "./TemplatePreviewModal";
|
||||
|
||||
const DIRECTION_LABEL: Record<string, string> = {
|
||||
@@ -68,6 +71,14 @@ function customsVariant(template: ContractTemplate): boolean | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bulk templates carry the direction on the row; the fixed container codes
|
||||
// carry it as the code prefix.
|
||||
function directionOf(template: ContractTemplate): string {
|
||||
return isBulk(template)
|
||||
? template.tradeDirection ?? "INTERCITY"
|
||||
: template.code.split("_")[0];
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
@@ -105,7 +116,7 @@ export default function ContractTemplatesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
|
||||
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
@@ -194,9 +205,13 @@ export default function ContractTemplatesPage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff pick the customs option first, then a bulk cargo type that has
|
||||
* "has contract template" enabled. One template per combination — the API
|
||||
* rejects duplicates, so an existing pairing must be edited instead.
|
||||
* Staff pick the trade direction, then the customs option, then a bulk cargo
|
||||
* type that has "has contract template" enabled. One template per
|
||||
* (direction, customs, cargo type) combination — the API rejects duplicates,
|
||||
* so an existing combination must be edited instead.
|
||||
*
|
||||
* Intercity is domestic and crosses no border, so the customs choice does not
|
||||
* apply there and is hidden.
|
||||
*/
|
||||
function CreateTemplateModal({
|
||||
opened,
|
||||
@@ -207,9 +222,11 @@ function CreateTemplateModal({
|
||||
onClose: () => void;
|
||||
onCreated: (code: string) => void;
|
||||
}) {
|
||||
const [direction, setDirection] = useState<BulkTemplateDirection>("IMPORT");
|
||||
const [withCustoms, setWithCustoms] = useState<string>("true");
|
||||
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
|
||||
const create = useCreateContractTemplate();
|
||||
const intercity = direction === "INTERCITY";
|
||||
|
||||
const { data: cargoTypes, isLoading } = useQuery({
|
||||
queryKey: ["cargo-types", "contract-template-options"],
|
||||
@@ -238,19 +255,42 @@ function CreateTemplateModal({
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={6}>
|
||||
Customs clearing
|
||||
Trade direction
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={withCustoms}
|
||||
onChange={setWithCustoms}
|
||||
value={direction}
|
||||
onChange={(value) => setDirection(value as BulkTemplateDirection)}
|
||||
data={[
|
||||
{ value: "true", label: "With customs clearing" },
|
||||
{ value: "false", label: "Without customs clearing" },
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
{ value: "INTERCITY", label: "Intercity" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{intercity ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Intercity contracts are domestic and cross no border, so they have
|
||||
no customs clearing variant — one template per cargo type.
|
||||
</Text>
|
||||
) : (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={6}>
|
||||
Customs clearing
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={withCustoms}
|
||||
onChange={setWithCustoms}
|
||||
data={[
|
||||
{ value: "true", label: "With customs clearing" },
|
||||
{ value: "false", label: "Without customs clearing" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Bulk cargo type"
|
||||
description="Only cargo types with “has contract template” enabled are listed"
|
||||
@@ -273,7 +313,12 @@ function CreateTemplateModal({
|
||||
onClick={() => {
|
||||
if (!cargoTypeId) return;
|
||||
create.mutate(
|
||||
{ cargoTypeId, withCustoms: withCustoms === "true" },
|
||||
{
|
||||
cargoTypeId,
|
||||
tradeDirection: direction,
|
||||
// Omitted for intercity — the API rejects the flag there.
|
||||
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
|
||||
},
|
||||
{
|
||||
onSuccess: (template) =>
|
||||
onCreated((template as ContractTemplate).code),
|
||||
@@ -305,10 +350,12 @@ function TemplateCard({
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const bulk = isBulk(template);
|
||||
const direction = template.code.split("_")[0];
|
||||
const direction = directionOf(template);
|
||||
const customs = customsVariant(template);
|
||||
const kicker = bulk
|
||||
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
|
||||
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · ${
|
||||
DIRECTION_LABEL[direction] ?? direction
|
||||
} · Bulk`
|
||||
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
|
||||
|
||||
return (
|
||||
@@ -336,9 +383,8 @@ function TemplateCard({
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: bulk
|
||||
? "var(--mantine-color-teal-5)"
|
||||
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||
background:
|
||||
DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Banknote,
|
||||
Contact,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
FilePen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
PersonCard,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
ProfileStatusBadge,
|
||||
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(f.id, f.name).then(view)
|
||||
}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
@@ -615,7 +623,10 @@ export default function CustomerDetailPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
||||
/** Never read `company.companyProfiles` directly — an endpoint that stops
|
||||
* loading the relation would otherwise crash the whole page. */
|
||||
const profiles = company?.companyProfiles ?? [];
|
||||
const licenseProfiles = profiles.filter(
|
||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||
);
|
||||
|
||||
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
|
||||
[documents],
|
||||
);
|
||||
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||
const poaFields = [
|
||||
{ label: "PoA name", value: company?.poaName },
|
||||
{ label: "PoA email", value: company?.poaEmail },
|
||||
{ label: "PoA phone", value: company?.poaPhone },
|
||||
{ label: "PoA location", value: company?.poaLocation },
|
||||
{ label: "PoA address", value: company?.poaAddress },
|
||||
];
|
||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
||||
const hasPoaDetails = [
|
||||
company?.poaName,
|
||||
company?.poaEmail,
|
||||
company?.poaPhone,
|
||||
company?.poaLocation,
|
||||
company?.poaAddress,
|
||||
].some((v) => v?.trim());
|
||||
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||
// this page can never disagree with the rule the API actually enforces.
|
||||
const identityState = company?.identity;
|
||||
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
|
||||
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||
// and DARS delegation paper both — is mandatory rather than optional.
|
||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
|
||||
const delegationMissing =
|
||||
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
||||
|
||||
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Profiles",
|
||||
value: company.companyProfiles.length,
|
||||
value: profiles.length,
|
||||
icon: IdCard,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
|
||||
: "Pending approval",
|
||||
value: stillOnboarding
|
||||
? "—"
|
||||
: company.companyProfiles.filter(
|
||||
(p) => p.status === "pending",
|
||||
).length,
|
||||
: profiles.filter((p) => p.status === "pending").length,
|
||||
icon: IdCard,
|
||||
color: "yellow",
|
||||
},
|
||||
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Company information
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
ownerIdentity?.verified
|
||||
? "Fayda verified"
|
||||
: ownerIdentity?.passportNumber
|
||||
? `Passport ${ownerIdentity.passportNumber}`
|
||||
: "Not verified"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Country" value={company.country} />
|
||||
<InfoField
|
||||
label="Nationality"
|
||||
value={
|
||||
company.nationality
|
||||
? humanize(company.nationality)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* Why this company's registration was typed rather than
|
||||
fetched, and why it carries no business licence. */}
|
||||
<InfoField
|
||||
label="Registration"
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Address" value={company.address} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
<InfoField label="Email" value={company.email} />
|
||||
<InfoField label="Phone" value={company.phone} />
|
||||
<Box />
|
||||
<InfoField
|
||||
label="Contact person"
|
||||
value={company.contactPersonName}
|
||||
/>
|
||||
<InfoField
|
||||
label="Contact phone"
|
||||
value={company.contactPersonPhone}
|
||||
/>
|
||||
<Box />
|
||||
<InfoField label="Owner" value={company.ownerName} />
|
||||
<InfoField label="Owner email" value={company.ownerEmail} />
|
||||
<InfoField label="Owner phone" value={company.ownerPhone} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField
|
||||
label="License number"
|
||||
value={company.licenceNumber}
|
||||
/>
|
||||
<InfoField label="Status" value={company.statusDescription} />
|
||||
<InfoField
|
||||
label="Date registered"
|
||||
value={company.dateRegistered}
|
||||
/>
|
||||
<InfoField label="Renewed from" value={company.renewedFrom} />
|
||||
<InfoField label="Renewal date" value={company.renewalDate} />
|
||||
<InfoField label="Renewed to" value={company.renewedTo} />
|
||||
<InfoField label="Region" value={company.region} />
|
||||
<InfoField label="Zone" value={company.zone} />
|
||||
<InfoField label="Woreda" value={company.woreda} />
|
||||
<InfoField label="Kebele" value={company.kebele} />
|
||||
<InfoField label="House No" value={company.houseNo} />
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No eTrade registration record on file for this customer's
|
||||
TIN.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
Owner identity
|
||||
</Text>
|
||||
{identityState?.subject === "owner" && (
|
||||
<Badge size="sm" color="blue" variant="light">
|
||||
Verifies for this company
|
||||
</Badge>
|
||||
)}
|
||||
{ownerIdentity?.verified ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Fayda verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
Not verified
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* THE check: is the owner the company put forward the person
|
||||
the eTrade licence actually names? Advisory — eTrade and
|
||||
Fayda transliterate Amharic names differently, so this is a
|
||||
prompt to look, not a verdict. */}
|
||||
{identityState?.ownerMatchesEtrade === false ? (
|
||||
<Alert
|
||||
color="amber"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Does not match the eTrade licence"
|
||||
>
|
||||
The licence names{" "}
|
||||
<strong>{identityState.etradeManagerName}</strong>, but this
|
||||
company recorded <strong>{company.ownerName}</strong>.
|
||||
</Alert>
|
||||
) : identityState?.ownerMatchesEtrade === true ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Matches the eTrade licence
|
||||
</Badge>
|
||||
) : company.cooperative ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
A co-operative union or farm holds no trade licence, so
|
||||
there is no eTrade record to check the owner against.
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
No eTrade manager name on file to compare against.
|
||||
</Text>
|
||||
)}
|
||||
{ownerIdentity?.verified ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField label="Name" value={ownerIdentity.name} />
|
||||
<InfoField label="Phone" value={ownerIdentity.phone} />
|
||||
<InfoField label="Email" value={ownerIdentity.email} />
|
||||
<InfoField label="Address" value={ownerIdentity.address} />
|
||||
<InfoField
|
||||
label="Verified at"
|
||||
value={formatDate(ownerIdentity.verifiedAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Birthdate"
|
||||
value={ownerIdentity.birthdate}
|
||||
/>
|
||||
<InfoField label="Gender" value={ownerIdentity.gender} />
|
||||
<InfoField
|
||||
label="Passport number"
|
||||
value={ownerIdentity.passportNumber}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{ownerIdentity?.passportNumber
|
||||
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
||||
: "The company owner has not verified their identity with Fayda."}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
Power of Attorney
|
||||
</Text>
|
||||
{poaMandatory && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Required for freight forwarder
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{delegationMissing ? (
|
||||
<Badge size="sm" color="red" variant="light">
|
||||
DARS delegation paper missing
|
||||
</Badge>
|
||||
) : poaLive.length > 0 ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
DARS delegation paper on file
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
Not provided
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{hasPoaDetails ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{poaFields.map((f) => (
|
||||
<InfoField
|
||||
key={f.label}
|
||||
label={f.label}
|
||||
value={f.value}
|
||||
/>
|
||||
))}
|
||||
<InfoField
|
||||
label="PoA Fayda"
|
||||
value={
|
||||
poaIdentity?.verified ? "Verified" : "Not verified"
|
||||
}
|
||||
/>
|
||||
{poaIdentity?.verified && (
|
||||
<>
|
||||
<Grid gap="lg" align="flex-start">
|
||||
{/* Company facts — the wide column. People live in the narrow one
|
||||
beside it, so nothing about a person is stated twice. */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Company information
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField
|
||||
label="PoA verified at"
|
||||
value={formatDate(poaIdentity.verifiedAt)}
|
||||
label="VAT number"
|
||||
value={company.vatNumber}
|
||||
/>
|
||||
<InfoField label="Country" value={company.country} />
|
||||
<InfoField
|
||||
label="Nationality"
|
||||
value={
|
||||
company.nationality
|
||||
? humanize(company.nationality)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* Why this company's registration was typed rather
|
||||
than fetched, and why it carries no licence. */}
|
||||
<InfoField
|
||||
label="Registration"
|
||||
value={
|
||||
company.cooperative
|
||||
? "Co-operative union / farm (no trade licence)"
|
||||
: "eTrade trade licence"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Address" value={company.address} />
|
||||
<InfoField label="Email" value={company.email} />
|
||||
<InfoField label="Phone" value={company.phone} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="PoA birthdate"
|
||||
value={poaIdentity.birthdate}
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No Power of Attorney representative recorded for this
|
||||
customer.
|
||||
</Text>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-muted"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
DARS delegation paper
|
||||
</Text>
|
||||
|
||||
{documentsQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading documents…
|
||||
</Text>
|
||||
</Group>
|
||||
) : documentsQuery.isError ? (
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="red">
|
||||
Failed to load documents.
|
||||
</Text>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => void documentsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : poaDocuments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No DARS delegation paper uploaded.
|
||||
</Text>
|
||||
) : (
|
||||
poaDocuments.map((doc) => (
|
||||
<Group key={doc.id} justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Paperclip
|
||||
size={14}
|
||||
className="shrink-0 text-edr-muted"
|
||||
/>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
||||
}
|
||||
>
|
||||
{doc.name}
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed" className="shrink-0">
|
||||
{formatBytes(doc.size)} ·{" "}
|
||||
{formatDate(doc.uploadedAt)}
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||
<Badge
|
||||
size="xs"
|
||||
color="yellow"
|
||||
variant="light"
|
||||
className="shrink-0"
|
||||
>
|
||||
Pending approval
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Preview ${doc.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
||||
}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.id, doc.name)
|
||||
}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Download ${doc.name}`}
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
{hasEtradeRecord ? (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="lg"
|
||||
>
|
||||
<InfoField
|
||||
label="License number"
|
||||
value={company.licenceNumber}
|
||||
/>
|
||||
<InfoField
|
||||
label="Status"
|
||||
value={company.statusDescription}
|
||||
/>
|
||||
<InfoField
|
||||
label="Date registered"
|
||||
value={company.dateRegistered}
|
||||
/>
|
||||
<InfoField
|
||||
label="Renewed from"
|
||||
value={company.renewedFrom}
|
||||
/>
|
||||
<InfoField
|
||||
label="Renewal date"
|
||||
value={company.renewalDate}
|
||||
/>
|
||||
<InfoField
|
||||
label="Renewed to"
|
||||
value={company.renewedTo}
|
||||
/>
|
||||
<InfoField label="Region" value={company.region} />
|
||||
<InfoField label="Zone" value={company.zone} />
|
||||
<InfoField label="Woreda" value={company.woreda} />
|
||||
<InfoField label="Kebele" value={company.kebele} />
|
||||
<InfoField label="House No" value={company.houseNo} />
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No eTrade registration record on file for this
|
||||
customer's TIN.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={company.companyProfiles} />
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={company.companyProfiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={profiles} />
|
||||
</Group>
|
||||
{/* Narrower than the old full-width layout — the table
|
||||
shares the row with the people column now. */}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={760}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* People: owner, then power of attorney, then contact person —
|
||||
the order a reviewer checks them in. */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
<PersonCard
|
||||
title="Owner"
|
||||
icon={<UserRound size={15} className="text-edr-muted" />}
|
||||
verified={Boolean(ownerIdentity?.verified)}
|
||||
badges={
|
||||
<>
|
||||
{identityState?.subject === "owner" && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Verifies for this company
|
||||
</Badge>
|
||||
)}
|
||||
{identityState?.ownerMatchesEtrade === true && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Matches eTrade licence
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
notice={
|
||||
/* THE check: is the owner the company put forward the
|
||||
person the eTrade licence actually names? Advisory —
|
||||
eTrade and Fayda transliterate Amharic names
|
||||
differently, so this is a prompt to look, not a
|
||||
verdict. */
|
||||
identityState?.ownerMatchesEtrade === false ? (
|
||||
<Alert
|
||||
color="amber"
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Does not match the eTrade licence"
|
||||
>
|
||||
<Text size="xs">
|
||||
The licence names{" "}
|
||||
<strong>{identityState.etradeManagerName}</strong>,
|
||||
but this company recorded{" "}
|
||||
<strong>{company.ownerName ?? "nobody"}</strong>.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : !ownerIdentity?.verified &&
|
||||
ownerIdentity?.passportNumber ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Identified by passport rather than Fayda.
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Name",
|
||||
value: company.ownerName ?? ownerIdentity?.name,
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: company.ownerEmail ?? ownerIdentity?.email,
|
||||
},
|
||||
{
|
||||
label: "Phone",
|
||||
value: company.ownerPhone ?? ownerIdentity?.phone,
|
||||
},
|
||||
{
|
||||
label: "Passport number",
|
||||
value: ownerIdentity?.passportNumber,
|
||||
},
|
||||
{ label: "Address", value: ownerIdentity?.address },
|
||||
{ label: "Birthdate", value: ownerIdentity?.birthdate },
|
||||
{ label: "Gender", value: ownerIdentity?.gender },
|
||||
{
|
||||
label: "Verified on",
|
||||
value: ownerIdentity?.verifiedAt
|
||||
? formatDate(ownerIdentity.verifiedAt)
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
emptyMessage="No owner recorded for this company."
|
||||
/>
|
||||
|
||||
<PersonCard
|
||||
title="Power of attorney"
|
||||
icon={<UserCheck size={15} className="text-edr-muted" />}
|
||||
verified={
|
||||
// No PoA at all → no badge, rather than a "not verified"
|
||||
// that reads as a problem where none exists.
|
||||
hasPoaDetails || identityState?.poaDeclared === "yes"
|
||||
? Boolean(poaIdentity?.verified)
|
||||
: undefined
|
||||
}
|
||||
badges={
|
||||
<>
|
||||
{identityState?.subject === "poa" && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Verifies for this company
|
||||
</Badge>
|
||||
)}
|
||||
{poaMandatory && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Required for freight forwarder
|
||||
</Badge>
|
||||
)}
|
||||
{delegationMissing && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Delegation paper missing
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Name", value: company.poaName },
|
||||
{ label: "Email", value: company.poaEmail },
|
||||
{ label: "Phone", value: company.poaPhone },
|
||||
{ label: "Location", value: company.poaLocation },
|
||||
{ label: "Address", value: company.poaAddress },
|
||||
{ label: "Birthdate", value: poaIdentity?.birthdate },
|
||||
{ label: "Gender", value: poaIdentity?.gender },
|
||||
{
|
||||
label: "Verified on",
|
||||
value: poaIdentity?.verifiedAt
|
||||
? formatDate(poaIdentity.verifiedAt)
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
emptyMessage="No representative recorded for this customer."
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="edr-muted">
|
||||
DARS delegation paper
|
||||
</Text>
|
||||
|
||||
{documentsQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading…
|
||||
</Text>
|
||||
</Group>
|
||||
) : documentsQuery.isError ? (
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="red">
|
||||
Failed to load documents.
|
||||
</Text>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => void documentsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : poaDocuments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Not uploaded.
|
||||
</Text>
|
||||
) : (
|
||||
poaDocuments.map((doc) => (
|
||||
<Stack key={doc.id} gap={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Paperclip
|
||||
size={13}
|
||||
className="shrink-0 text-edr-muted"
|
||||
/>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
style={{ flex: 1, textAlign: "left" }}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
}
|
||||
>
|
||||
{doc.name}
|
||||
</Anchor>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Preview ${doc.name}`}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(doc.id, doc.name).then(
|
||||
view,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
component="button"
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Download ${doc.name}`}
|
||||
onClick={() =>
|
||||
void downloadBookingFile(doc.id, doc.name)
|
||||
}
|
||||
>
|
||||
<Download size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group gap={6} pl={19} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(doc.size)} ·{" "}
|
||||
{formatDate(doc.uploadedAt)}
|
||||
</Text>
|
||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending approval
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</PersonCard>
|
||||
|
||||
<PersonCard
|
||||
title="Contact person"
|
||||
icon={<Contact size={15} className="text-edr-muted" />}
|
||||
fields={[
|
||||
{ label: "Name", value: company.contactPersonName },
|
||||
{ label: "Phone", value: company.contactPersonPhone },
|
||||
]}
|
||||
emptyMessage="No contact person recorded."
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
|
||||
import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard";
|
||||
|
||||
export default function MyProfilePage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
|
||||
<ChangeEmailCard />
|
||||
<ChangePasswordCard />
|
||||
<div id="signature">
|
||||
<MySignatureCard />
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
|
||||
hasLashing?: boolean;
|
||||
/** When true, bookings of this cargo type incur the lane-scoped FUEL surcharge. */
|
||||
hasFuel?: boolean;
|
||||
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
|
||||
hasContractTemplate?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
@@ -114,6 +116,9 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
// When on, every booking of this cargo type is charged the flat LASHING
|
||||
// surcharge (a rate with trigger = Lashing).
|
||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
||||
// When on, bookings of this cargo type incur the fuel surcharge, billed off
|
||||
// the lane-scoped FUEL rate (direction + route + cargo type) under Rates.
|
||||
{ name: "hasFuel", label: "Charge fuel fee", type: "boolean" },
|
||||
// Lets staff write bulk contract templates for this cargo type. The API
|
||||
// rejects the save when the parent group (or a child) already has it on —
|
||||
// the template must live on exactly one level.
|
||||
|
||||
@@ -100,19 +100,27 @@ const yardOptionsForLegEnd = (
|
||||
} else if (
|
||||
appliesTo === "CONTAINER" ||
|
||||
appliesTo === "BULK" ||
|
||||
// Customs clearance + empty-container return are sold per direction +
|
||||
// route, so their yard dropdowns narrow exactly like base freight.
|
||||
// Customs clearance, empty-container return and fuel are sold per
|
||||
// direction + route, so their yard dropdowns narrow exactly like base
|
||||
// freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
// rather than defaulting to one and letting it read as a real choice.
|
||||
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
|
||||
const startsInEthiopia = direction === "EXPORT";
|
||||
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
|
||||
? "Ethiopia"
|
||||
: "Djibouti";
|
||||
if (direction === "DOMESTIC") {
|
||||
// A fuel rate's intercity lane — stays inside Ethiopia.
|
||||
country = "Ethiopia";
|
||||
} else {
|
||||
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
|
||||
const startsInEthiopia = direction === "EXPORT";
|
||||
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
|
||||
? "Ethiopia"
|
||||
: "Djibouti";
|
||||
}
|
||||
}
|
||||
if (!country) return [];
|
||||
return yards
|
||||
|
||||
@@ -181,6 +181,17 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fuel lanes: import/export like base freight, plus a domestic intercity lane
|
||||
* (stored as DOMESTIC — matches the booking's own trade direction).
|
||||
*/
|
||||
const FUEL_TRADE_DIRECTIONS = [
|
||||
{ label: "Import", value: "IMPORT" },
|
||||
{ label: "Export", value: "EXPORT" },
|
||||
{ label: "Intercity", value: "DOMESTIC" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -205,7 +216,7 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
@@ -255,6 +266,9 @@ const unitsForShape = (
|
||||
case "LASHING":
|
||||
// Bulk-only cargo securing — per ton or per wagon.
|
||||
return ["PER_TON", "PER_WAGON"];
|
||||
case "FUEL":
|
||||
// Per wagon (wagons × rate) or per liter (base liters × rate, once).
|
||||
return ["PER_WAGON", "PER_LITER"];
|
||||
case "CONSOLIDATION":
|
||||
case "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
@@ -371,6 +385,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
|
||||
{
|
||||
name: "hasFuel",
|
||||
label: "Charge fuel fee",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Bookings of this cargo incur the fuel surcharge (configure the FUEL rate per lane under Rates).",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -834,7 +855,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -886,14 +907,17 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: (v: Record<string, unknown>) =>
|
||||
String(v.trigger ?? "") === "WITH_RETURN" &&
|
||||
String(v.appliesTo ?? "") === "OTHER"
|
||||
String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "WITH_RETURN"
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
: String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "FUEL"
|
||||
? FUEL_TRADE_DIRECTIONS
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
)),
|
||||
},
|
||||
@@ -939,6 +963,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
v.cargoKind === "BULK",
|
||||
},
|
||||
// ── Cargo type — a fuel rate names the commodity it covers (different
|
||||
// commodities price differently on the same lane) ──────────────────────
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Cargo type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which cargo type this fuel rate covers",
|
||||
description:
|
||||
"Fuel is charged for bookings of this cargo type (needs “Charge fuel fee” enabled on the cargo type).",
|
||||
showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL",
|
||||
},
|
||||
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
|
||||
// commodity (specific wins over the commodity-wide catch-all) ──────────
|
||||
{
|
||||
@@ -1123,6 +1159,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||
},
|
||||
// ── Base liters — per-liter fuel rates only ────────────────────────────
|
||||
{
|
||||
name: "baseLiters",
|
||||
label: "Base (liters)",
|
||||
type: "number",
|
||||
required: true,
|
||||
placeholder: "e.g. 100",
|
||||
description:
|
||||
"Liters the surcharge covers — price = base liters × rate value, charged once per booking.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
v.trigger === "FUEL" &&
|
||||
v.rateUnit === "PER_LITER",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,10 +17,16 @@ import {
|
||||
} from "@/hooks/useStampSettings";
|
||||
|
||||
/**
|
||||
* The one company stamp/seal stamped onto every generated invoice/receipt
|
||||
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
|
||||
* The ONE company stamp/seal, read by every document path server-side via
|
||||
* StampSettingsService: invoices and receipts (InvoiceDocumentService),
|
||||
* warehouse release + handover papers, and the EDR side of contract signature
|
||||
* blocks. Single global image — no per-employee choice, and staff never upload
|
||||
* one when signing.
|
||||
*
|
||||
* Not to be confused with the per-officer teeter (ማህተም) at
|
||||
* /user-management/teeter-and-signature, which is genuinely per-person.
|
||||
*/
|
||||
export default function InvoiceStampSettingsPage() {
|
||||
export default function CompanyStampSettingsPage() {
|
||||
const { data, isLoading } = useStampSettingsQuery();
|
||||
const setStamp = useSetStamp();
|
||||
const clearStamp = useClearStamp();
|
||||
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
|
||||
<div className="p-4 w-full max-w-screen-sm mx-auto">
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Invoice stamp</CardTitle>
|
||||
<CardTitle>Company stamp</CardTitle>
|
||||
<CardDescription>
|
||||
Stamped onto every generated invoice and receipt PDF. Replacing it
|
||||
here changes it everywhere at once — there is no per-invoice or
|
||||
per-user choice.
|
||||
The single EDR seal, applied to every generated document — invoices
|
||||
and receipts, warehouse release and handover papers, and the EDR
|
||||
side of signed contracts. Replacing it here changes it everywhere at
|
||||
once; there is no per-document, per-invoice or per-employee choice.
|
||||
Staff do not upload their own.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -1019,14 +1019,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
/**
|
||||
* The row's wagon chips, matching the detail page's wagon plan: used is slots
|
||||
* carrying a booking allocation (never the coupled consist size), and remaining
|
||||
* excludes wagons reserved by bookings that have not paid yet — that space is
|
||||
* claimed, so it is not bookable.
|
||||
*
|
||||
* Schedules whose train set has not been built yet have no consist to measure,
|
||||
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
|
||||
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
|
||||
* though every one of its wagons is still free.
|
||||
* carrying a booking allocation, the denominator is the schedule's capacity
|
||||
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
|
||||
* wagons are coupled on demand), and remaining excludes wagons reserved by
|
||||
* bookings that have not paid yet — that space is claimed, so it is not
|
||||
* bookable.
|
||||
*/
|
||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||
@@ -1034,17 +1031,7 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
const total = schedule.wagonsTotal ?? schedule.wagonCount;
|
||||
const used = schedule.wagonsUsed;
|
||||
const reserved = schedule.wagonsReserved ?? 0;
|
||||
|
||||
// Until the train set is built there is no consist to measure against, so
|
||||
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
|
||||
// which reads as "fully booked" when in fact nothing is booked at all. Before
|
||||
// a consist exists, capacity is the planned ceiling minus what bookings have
|
||||
// already claimed.
|
||||
const planCeiling = schedule.maxWagons ?? 0;
|
||||
const remaining =
|
||||
total === 0 && planCeiling > 0
|
||||
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
|
||||
: schedule.wagonsRemaining;
|
||||
const remaining = schedule.wagonsRemaining;
|
||||
|
||||
if (used == null) {
|
||||
return <MetricChip value={total} label="wgn" subtle />;
|
||||
@@ -1052,11 +1039,9 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* An unbuilt consist has no "used out of coupled" to show; the plan
|
||||
ceiling is the only meaningful denominator at that point. */}
|
||||
<MetricChip
|
||||
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
|
||||
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
|
||||
value={`${used}/${total}`}
|
||||
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
|
||||
/>
|
||||
{reserved > used ? (
|
||||
<MetricChip value={reserved} label="reserved" subtle />
|
||||
|
||||
@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
|
||||
onClick={() => navigate("/change-password")}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
>
|
||||
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span>{t("header.changePassword")}</span>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -136,6 +136,12 @@ import type {
|
||||
WarehouseZone,
|
||||
} from "@/types/warehouse";
|
||||
import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
accountService,
|
||||
type ChangePasswordPayload,
|
||||
type SendContactOtpPayload,
|
||||
type UpdateContactPayload,
|
||||
} from "./account.service";
|
||||
import {
|
||||
BookingListFilter,
|
||||
bookingsService,
|
||||
@@ -2182,6 +2188,29 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
account: {
|
||||
changePassword: endpoint<ChangePasswordPayload, void>(
|
||||
"me",
|
||||
"change-password",
|
||||
(payload) => accountService.changePassword(payload),
|
||||
),
|
||||
|
||||
sendContactOtp: endpoint<SendContactOtpPayload, { sentTo: string }>(
|
||||
"me",
|
||||
"send-contact-otp",
|
||||
(payload) => accountService.sendContactOtp(payload),
|
||||
),
|
||||
|
||||
updateContact: endpoint<
|
||||
UpdateContactPayload,
|
||||
{ success: true; value: string }
|
||||
>(
|
||||
"me",
|
||||
"update-contact",
|
||||
(payload) => accountService.updateContact(payload),
|
||||
),
|
||||
},
|
||||
|
||||
signatures: {
|
||||
mySignature: endpoint<void, SavedSignature | null>(
|
||||
"me",
|
||||
|
||||
@@ -2,6 +2,10 @@ import { api as client } from "../auth/http";
|
||||
|
||||
const BASE = "/contract-templates";
|
||||
|
||||
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
|
||||
|
||||
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
|
||||
|
||||
export interface ContractTemplateArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -23,7 +27,12 @@ export interface ContractTemplate {
|
||||
/** Bulk templates only: the cargo type this template is written for. */
|
||||
cargoTypeId?: string | null;
|
||||
cargoType?: { id: string; cargoTypeName: string } | null;
|
||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
|
||||
tradeDirection?: BulkTemplateDirection | null;
|
||||
/**
|
||||
* Bulk templates only: whether this is the with-customs-clearing variant.
|
||||
* Null for intercity — domestic movements have no customs leg.
|
||||
*/
|
||||
withCustoms?: boolean | null;
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
isSystem: boolean;
|
||||
@@ -33,7 +42,9 @@ export interface ContractTemplate {
|
||||
|
||||
export interface CreateContractTemplatePayload {
|
||||
cargoTypeId: string;
|
||||
withCustoms: boolean;
|
||||
tradeDirection: BulkTemplateDirection;
|
||||
/** Omitted for INTERCITY — the API rejects the flag there. */
|
||||
withCustoms?: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Rate {
|
||||
currency: string;
|
||||
rateValue: string;
|
||||
rateUnit: string;
|
||||
/** PER_LITER fuel rates only: price = baseLiters × rateValue, once per booking. */
|
||||
baseLiters?: string | null;
|
||||
status: string;
|
||||
proposedByStaffId: string;
|
||||
approvedByCeoId: string | null;
|
||||
|
||||
@@ -9,7 +9,8 @@ export type EimsInvoiceStatus =
|
||||
| "SUBMITTING"
|
||||
| "REGISTERED"
|
||||
| "FAILED"
|
||||
| "UNKNOWN";
|
||||
| "UNKNOWN"
|
||||
| "CANCELLED";
|
||||
|
||||
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
|
||||
export interface EimsInvoiceError {
|
||||
@@ -30,6 +31,28 @@ export interface EimsInvoiceStatusView {
|
||||
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
|
||||
eimsAckDate: string | null;
|
||||
eimsLastError: EimsInvoiceError | null;
|
||||
/** Base64 PNG from MoR, already rendered on their side — embed as `data:image/png;base64,...`. */
|
||||
eimsSignedQr: string | null;
|
||||
eimsCancelledAt: string | null;
|
||||
/** MoR's own confirmation string (a Java `Date#toString()`), stored verbatim — display as-is. */
|
||||
eimsCancellationDate: string | null;
|
||||
eimsCancellationReasonCode: string | null;
|
||||
eimsCancellationRemark: string | null;
|
||||
}
|
||||
|
||||
/** One `EimsReceipt` row — mirrors `entities/eims-receipt.entity.ts`. */
|
||||
export interface EimsReceiptView {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
kind: "SALES" | "WITHHOLDING";
|
||||
status: EimsInvoiceStatus;
|
||||
receiptNumber: string;
|
||||
rrn: string | null;
|
||||
/** Base64 PNG, same convention as `eimsSignedQr`. */
|
||||
qr: string | null;
|
||||
ackStatus: string | null;
|
||||
submittedAt: string | null;
|
||||
lastError: EimsInvoiceError | null;
|
||||
}
|
||||
|
||||
/** `POST /v1/verify` response, echoed back from the gateway. */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
|
||||
import { isSuperAdmin } from "@/lib/permissions";
|
||||
import { FREIGHT_PERMS, isSuperAdmin } from "@/lib/permissions";
|
||||
import { WithPermission } from "@/shared/hooks/useHas";
|
||||
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
|
||||
import TemplatePage from "@/super-admin/components/templates/components/templates";
|
||||
@@ -44,6 +44,8 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
|
||||
import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
|
||||
import { PermissionProvider } from "@/shared/context/PermissionContext";
|
||||
import UserManagementPage from "@/pages/UserManagementPage";
|
||||
import { RequirePermission } from "@/components/auth/RequirePermission";
|
||||
import UploadTeeterAndSignature from "@/record-management/components/Settings/uploadTeeterandSingature";
|
||||
|
||||
/**
|
||||
* Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts
|
||||
@@ -138,6 +140,29 @@ export function UserManagementRoutes(): ReactElement {
|
||||
path="user-management/position-management"
|
||||
element={<PositionManagementPage />}
|
||||
/>
|
||||
{/*
|
||||
The per-officer teeter (ማህተም) + signature upload. This
|
||||
is NOT the company stamp: it is the individual approval
|
||||
stamp a record officer applies to records, locale-aware
|
||||
(am/en) and genuinely per-person. It used to sit at
|
||||
/dashboard/stamp-settings under Settings, next to the
|
||||
single global company stamp, which read as duplication.
|
||||
|
||||
Gated by settings:teeter:*, split out of settings:stamp:*
|
||||
when the two were untangled — settings:stamp:* now means
|
||||
the company stamp, so anyone who held it for the teeter
|
||||
needs the new key granted.
|
||||
*/}
|
||||
<Route
|
||||
path="user-management/teeter-and-signature"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.teeter.view}
|
||||
>
|
||||
<UploadTeeterAndSignature />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/migrated-records-management"
|
||||
element={<MigratedDataManagementPage />}
|
||||
|
||||
@@ -169,7 +169,8 @@ export default function OnboardingWizardDialog({
|
||||
setCooperative(checked);
|
||||
if (checked) {
|
||||
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
|
||||
// Ethiopian is then the only answer left, so it is made rather than asked.
|
||||
setNationality("ethiopian");
|
||||
}
|
||||
}, []);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user