From 7c78a815eb6f222d3878e8efb293afa32c8fd92b Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 09:48:49 +0000 Subject: [PATCH 1/4] add custom contrat templates --- .../contract-document-view-model.builder.ts | 1 + ...0000000-SplitContractTemplatesByCustoms.ts | 101 ++++++++++++++++++ .../contract-template-code.spec.ts | 66 ++++++++++++ .../contract-templates.service.spec.ts | 20 ++-- .../contract-templates.service.ts | 30 ++++-- .../entities/contract-template.entity.ts | 46 ++++++-- .../contracts/contract-transition.service.ts | 1 + .../seed/data/contract-template-defaults.ts | 97 +++++++++++++---- .../ContractTemplatesPage.tsx | 48 +++++++-- .../services/contract-templates.service.ts | 14 ++- 10 files changed, 366 insertions(+), 58 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index eb9541d8e..e6fcaf8f7 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder { const dynamicSource = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, + contract.customsClearingEnabled, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts b/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts new file mode 100644 index 000000000..88e715b05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts @@ -0,0 +1,101 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * The codes that gain a customs variant. Intercity is deliberately absent: it + * is a domestic Ethiopian movement that crosses no border, so it has no customs + * leg and keeps its single unsuffixed template. + */ +const SPLIT_CODES = [ + 'IMPORT_BULK', + 'EXPORT_BULK', + 'IMPORT_CONTAINER', + 'EXPORT_CONTAINER', +]; + +/** + * Split the four cross-border contract templates into eight — one `_CUSTOMS` + * and one `_NO_CUSTOMS` variant each — so the generated contract document + * reflects whether EDR clears customs on the Client's behalf. Together with the + * two untouched intercity templates the table ends up with ten rows. + * + * The four existing rows are RENAMED to `_NO_CUSTOMS` rather than + * replaced, so any article text staff already edited through the template + * editor survives. The four `_CUSTOMS` rows are then inserted from the seed + * (the same base pack plus the customs-clearing articles). + * + * Idempotent: the rename is guarded on the legacy code still existing, and the + * insert is ON CONFLICT (code) DO NOTHING. + */ +export class SplitContractTemplatesByCustoms3230000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // 1. Carry each legacy row over to its _NO_CUSTOMS code, preserving edits. + // Guarded so a re-run (or a DB already holding the new code) is a no-op. + for (const legacy of SPLIT_CODES) { + await queryRunner.query( + ` + UPDATE freight.contract_templates + SET code = $2, updated_at = now() + WHERE code = $1 + AND NOT EXISTS ( + SELECT 1 FROM freight.contract_templates WHERE code = $2 + ); + `, + [legacy, `${legacy}_NO_CUSTOMS`], + ); + } + + // 2. Seed anything still missing — the six _CUSTOMS rows on an existing DB, + // or all twelve on a database that never held the legacy codes. + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const articles = seed.articles.map((article, index) => ({ + ...article, + order: index + 1, + })); + await queryRunner.query( + ` + INSERT INTO freight.contract_templates + (code, name, description, document_title, whereas_clauses, articles) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (code) DO NOTHING; + `, + [ + seed.code, + seed.name, + seed.description, + seed.documentTitle, + JSON.stringify(seed.whereasClauses), + JSON.stringify(articles), + ], + ); + } + } + + /** + * Drop the _CUSTOMS rows and fold the _NO_CUSTOMS rows back onto the legacy + * codes, returning the table to six templates. The two intercity rows were + * never touched by up(), so they need no reversal. + */ + public async down(queryRunner: QueryRunner): Promise { + for (const legacy of SPLIT_CODES) { + await queryRunner.query( + `DELETE FROM freight.contract_templates WHERE code = $1;`, + [`${legacy}_CUSTOMS`], + ); + await queryRunner.query( + ` + UPDATE freight.contract_templates + SET code = $1, updated_at = now() + WHERE code = $2 + AND NOT EXISTS ( + SELECT 1 FROM freight.contract_templates WHERE code = $1 + ); + `, + [legacy, `${legacy}_NO_CUSTOMS`], + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts new file mode 100644 index 000000000..803814967 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -0,0 +1,66 @@ +import { + CONTRACT_TEMPLATE_CODES, + contractTemplateCodeFor, +} from './entities/contract-template.entity'; +import { CONTRACT_TEMPLATE_DEFAULTS } from '../../seed/data/contract-template-defaults'; + +describe('contractTemplateCodeFor', () => { + it('splits import and export by the customs flag', () => { + expect(contractTemplateCodeFor('IMPORT', 'BULK', true)).toBe('IMPORT_BULK_CUSTOMS'); + expect(contractTemplateCodeFor('IMPORT', 'BULK', false)).toBe('IMPORT_BULK_NO_CUSTOMS'); + expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', true)).toBe( + 'EXPORT_CONTAINER_CUSTOMS', + ); + expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', false)).toBe( + 'EXPORT_CONTAINER_NO_CUSTOMS', + ); + }); + + it('never gives intercity a customs variant — it crosses no border', () => { + for (const flag of [true, false, null, undefined]) { + expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK'); + expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', flag)).toBe( + 'INTERCITY_CONTAINER', + ); + } + }); + + it('treats a missing customs flag as no customs on cross-border contracts', () => { + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', null)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', undefined)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + }); + + it('only ever resolves to a code that exists', () => { + const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null]; + const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null]; + for (const d of directions) { + for (const f of freights) { + for (const c of [true, false]) { + expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c)); + } + } + } + }); +}); + +describe('CONTRACT_TEMPLATE_DEFAULTS', () => { + it('seeds exactly the ten declared codes, once each', () => { + const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort(); + expect(seeded).toHaveLength(10); + expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort()); + }); + + it('gives every _CUSTOMS template the customs articles and no other one', () => { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const hasCustomsArticle = seed.articles.some((a) => a.id === 'customs-clearing'); + // Note "_NO_CUSTOMS" also ends with "_CUSTOMS" — exclude it explicitly. + const isCustomsVariant = + seed.code.endsWith('_CUSTOMS') && !seed.code.endsWith('_NO_CUSTOMS'); + expect(hasCustomsArticle).toBe(isCustomsVariant); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts index 0478db102..11a838223 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts @@ -25,11 +25,19 @@ function seededTemplate(code: string): ContractTemplate { } describe("contractTemplateCodeFor", () => { - it("maps every direction/freight pair to one of the six codes", () => { - expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK"); - expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER"); - expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER"); - expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK"); + it("maps every direction/freight/customs triple to one of the ten codes", () => { + expect(contractTemplateCodeFor("IMPORT", "BULK", true)).toBe("IMPORT_BULK_CUSTOMS"); + expect(contractTemplateCodeFor("IMPORT", "BULK", false)).toBe( + "IMPORT_BULK_NO_CUSTOMS", + ); + expect(contractTemplateCodeFor("EXPORT", "CONTAINER", true)).toBe( + "EXPORT_CONTAINER_CUSTOMS", + ); + // Intercity is domestic — no border, so no customs variant either way. + expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER", true)).toBe( + "INTERCITY_CONTAINER", + ); + expect(contractTemplateCodeFor("DOMESTIC", "BULK", false)).toBe("INTERCITY_BULK"); expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER"); }); }); @@ -60,7 +68,7 @@ describe("ContractTemplatesService.preview", () => { ); it("interpolates {{contractYear}} inside seeded article bodies", async () => { - const { html } = await service.preview("IMPORT_BULK"); + const { html } = await service.preview("IMPORT_BULK_CUSTOMS"); expect(html).toContain(`August 31, ${new Date().getFullYear()}`); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2755fece..cb956878d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -24,13 +24,22 @@ import { contractTemplateCodeFor, } from "./entities/contract-template.entity"; -/** Registry keys used to derive labels for the mock preview per template code. */ +/** + * Registry keys used to derive labels for the mock preview per template code. + * The registry's FORWARDING scope carries the customs/clearing clause pack, so + * the `_CUSTOMS` codes preview against it and `_NO_CUSTOMS` against + * TRANSPORT_ONLY. + */ const PREVIEW_TEMPLATE_KEYS: Record = { - IMPORT_BULK: "IMP_BULK_USD_FORWARDING", - EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY", + IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING", + IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY", + EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING", + EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY", INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", - IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY", - EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING", + IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING", + IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY", + EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING", + EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY", INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", }; @@ -59,14 +68,19 @@ export class ContractTemplatesService { /** * The active template used when generating a contract document for the given - * direction/freight pair; null when missing or deactivated (the renderer then - * falls back to the built-in generic layout). + * direction/freight/customs triple; null when missing or deactivated (the + * renderer then falls back to the built-in generic layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, + customsClearingEnabled?: boolean | null, ): Promise { - const code = contractTemplateCodeFor(tradeDirection, freightType); + const code = contractTemplateCodeFor( + tradeDirection, + freightType, + customsClearingEnabled, + ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; } diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index 73729fbb6..c322c20a4 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -2,17 +2,30 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index } from "typeorm"; /** - * The six canonical contract document templates, one per - * (trade direction × freight type) combination. Contracts store DOMESTIC for - * intercity movements; the template layer labels those INTERCITY to match the - * commercial vocabulary used on the printed documents. + * The ten canonical contract document templates. Import and export split by + * customs clearing (× freight type = 8); intercity does not, because it is a + * purely domestic Ethiopian movement that crosses no border and therefore has + * no customs leg at all (× freight type = 2). + * + * Contracts store DOMESTIC for intercity movements; the template layer labels + * those INTERCITY to match the commercial vocabulary used on the printed + * documents. + * + * The `_CUSTOMS` variant is issued when the contract has customs clearing + * enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's + * behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles + * its own declarations. */ export const CONTRACT_TEMPLATE_CODES = [ - "IMPORT_BULK", - "EXPORT_BULK", + "IMPORT_BULK_CUSTOMS", + "IMPORT_BULK_NO_CUSTOMS", + "EXPORT_BULK_CUSTOMS", + "EXPORT_BULK_NO_CUSTOMS", "INTERCITY_BULK", - "IMPORT_CONTAINER", - "EXPORT_CONTAINER", + "IMPORT_CONTAINER_CUSTOMS", + "IMPORT_CONTAINER_NO_CUSTOMS", + "EXPORT_CONTAINER_CUSTOMS", + "EXPORT_CONTAINER_NO_CUSTOMS", "INTERCITY_CONTAINER", ] as const; @@ -33,10 +46,19 @@ export interface ContractTemplateArticle { order: number; } -/** Map a contract's stored direction/freight pair onto a template code. */ +/** + * Map a contract's stored direction/freight/customs triple onto a template + * code. `customsClearingEnabled` is treated as false when absent so an older + * contract row with a null flag still resolves to a real template rather than + * falling through to the generic layout. + * + * Intercity is domestic and has no customs leg, so it resolves to a single + * unsuffixed code regardless of the flag. + */ export function contractTemplateCodeFor( tradeDirection?: string | null, freightType?: string | null, + customsClearingEnabled?: boolean | null, ): ContractTemplateCode { const direction = tradeDirection === "IMPORT" @@ -46,7 +68,11 @@ export function contractTemplateCodeFor( : "INTERCITY"; const freight = (freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER"; - return `${direction}_${freight}` as ContractTemplateCode; + if (direction === "INTERCITY") { + return `INTERCITY_${freight}` as ContractTemplateCode; + } + const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS"; + return `${direction}_${freight}_${customs}` as ContractTemplateCode; } @Entity({ schema: "freight", name: "contract_templates" }) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 1a4de8245..0a073c9f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -423,6 +423,7 @@ export class ContractTransitionService { const active = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, + contract.customsClearingEnabled, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 01442db07..6255c90fc 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -4,7 +4,7 @@ import type { } from "../../modules/contract-templates/entities/contract-template.entity"; /** - * Default article packs for the six contract templates, transcribed from the + * Default article packs for the ten contract templates, transcribed from the * signed EDR contract documents (test/contrat_docs). Article bodies use the * dynamic-article text format: one clause per line, "- " prefix for bullets * nested under the previous clause, single-line body = plain paragraph. @@ -20,6 +20,13 @@ export interface ContractTemplateSeed { articles: Array>; } +/** + * A base pack keyed by direction/freight only. Each one is transcribed from a + * signed EDR contract and is split at the bottom of this file into the + * `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores. + */ +type ContractTemplateBase = Omit; + const a = (id: string, title: string, body: string): Omit => ({ id, title, @@ -28,8 +35,7 @@ const a = (id: string, title: string, body: string): Omit> = [ + a( + "customs-clearing", + "Customs Clearing Services", + `The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement, including declaration, lodgement, and follow-up at the customs stations of Djibouti and Ethiopia as applicable to the agreed corridor. +The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction. +Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance. +The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`, + ), + a( + "customs-client-duties", + "Client Obligations for Customs Clearing", + `Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent for the duration of this Agreement. +Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request. +Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate. +Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation. +Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`, + ), +]; + +/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */ +function splitByCustoms( + base: ContractTemplateBase, + codeStem: string, +): ContractTemplateSeed[] { + return [ + { + ...base, + code: `${codeStem}_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (with customs clearing)`, + description: `${base.description} Customs clearing is performed by the Service Provider.`, + articles: [...base.articles, ...CUSTOMS_ARTICLES], + }, + { + ...base, + code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (without customs clearing)`, + description: `${base.description} Customs clearing is handled by the Client.`, + articles: [...base.articles], + }, + ]; +} + +/** + * Ten templates: import and export each split by customs clearing, intercity + * not split at all — it is a domestic Ethiopian movement that crosses no + * border, so there is no customs leg to contract for. + */ +export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [ + ...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"), + ...splitByCustoms(EXPORT_BULK_BASE, "EXPORT_BULK"), + { ...INTERCITY_BULK_BASE, code: "INTERCITY_BULK" }, + ...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"), + ...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"), + { ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 4a3654aa3..30b31f54d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -43,8 +43,19 @@ function templateDirection(code: ContractTemplate["code"]): string { return code.split("_")[0]; } +// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is +// the second one — never the suffix. function isBulk(code: ContractTemplate["code"]): boolean { - return code.endsWith("_BULK"); + return code.split("_")[1] === "BULK"; +} + +// Intercity is domestic and crosses no border, so it has no customs variant at +// all — hence null rather than false, which would wrongly read as a deliberate +// "client clears its own customs" choice. +function customsVariant(code: ContractTemplate["code"]): boolean | null { + if (code.endsWith("_NO_CUSTOMS")) return false; + if (code.endsWith("_CUSTOMS")) return true; + return null; } function formatUpdated(value: string): string { @@ -66,12 +77,12 @@ export default function ContractTemplatesPage() { {isLoading - ? Array.from({ length: 6 }, (_, i) => ) + ? Array.from({ length: 10 }, (_, i) => ) : (templates ?? []).map((template) => ( - {!template.isActive && ( - - - Inactive - - - )} + + {customs !== null && ( + + + {customs ? "With customs" : "No customs"} + + + )} + {!template.isActive && ( + + + Inactive + + + )} + {/* Name + description */} diff --git a/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts b/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts index 75202507b..267ee113a 100644 --- a/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts @@ -11,12 +11,18 @@ export interface ContractTemplateArticle { export interface ContractTemplate { id: string; + // Import/export split by customs clearing; intercity is domestic, crosses no + // border, and so has a single template. code: - | "IMPORT_BULK" - | "EXPORT_BULK" + | "IMPORT_BULK_CUSTOMS" + | "IMPORT_BULK_NO_CUSTOMS" + | "EXPORT_BULK_CUSTOMS" + | "EXPORT_BULK_NO_CUSTOMS" | "INTERCITY_BULK" - | "IMPORT_CONTAINER" - | "EXPORT_CONTAINER" + | "IMPORT_CONTAINER_CUSTOMS" + | "IMPORT_CONTAINER_NO_CUSTOMS" + | "EXPORT_CONTAINER_CUSTOMS" + | "EXPORT_CONTAINER_NO_CUSTOMS" | "INTERCITY_CONTAINER"; name: string; description?: string | null; From 50fab52b1c8f4a080ff5c438eacdfdaa4a9fb143 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 4 Aug 2026 09:54:15 +0000 Subject: [PATCH 2/4] feat(warehouses): filters, pagination and charts on container returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returned-containers list now uses the shared DataTable + useListControls (search, inclusive date range, status select, pagination) instead of a hand-rolled table. Adds two charts below the list — returns per day by truck type and returns by status — driven by the same filtered rows. Series colors validated for CVD separation and surface contrast. --- .../src/scripts/seed-warehouse-demo.ts | 29 +- .../src/seed/warehouse-demo.seeder.ts | 51 ++++ .../pages/warehouses/ContainerReturnsPage.tsx | 286 +++++++++++++----- 3 files changed, 287 insertions(+), 79 deletions(-) diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 24118d882..593abb305 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -5,6 +5,7 @@ import { resolve } from 'path'; config({ path: resolve(__dirname, '../../.env') }); import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; import { AppModule } from '../app.module'; import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder'; import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder'; @@ -14,19 +15,33 @@ import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder'; import { PricingDataSeeder } from '../seed/pricing-data.seeder'; import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder'; +/** Demo data only — refuse to run against anything but a local dev database. */ +function assertLocalhost() { + const host = process.env.DB_HOST ?? 'localhost'; + if (host !== 'localhost' && host !== '127.0.0.1') { + console.error(`Refusing to seed demo data: DB_HOST is "${host}", not localhost.`); + process.exit(1); + } +} + async function main() { + assertLocalhost(); + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn', 'log'], }); try { - await app.get(PricingDataSeeder).run(); - await app.get(IndodeFacilitySeeder).run(); - await app.get(Batch14TestDataSeeder).run(); - await app.get(Batch5TestDataSeeder).run(); - await app.get(Batch7TestDataSeeder).run(); - await app.get(Batch8TestDataSeeder).run(); - await app.get(WarehouseDemoSeeder).run(); + // Demo seeders are intentionally not AppModule providers (they'd run on every + // boot), so construct them against the app's DataSource instead of via DI. + const dataSource = app.get(DataSource); + await new PricingDataSeeder(dataSource).run(); + await new IndodeFacilitySeeder(dataSource).run(); + await new Batch14TestDataSeeder(dataSource).run(); + await new Batch5TestDataSeeder(dataSource).run(); + await new Batch7TestDataSeeder(dataSource).run(); + await new Batch8TestDataSeeder(dataSource).run(); + await new WarehouseDemoSeeder(dataSource).run(); console.log('Warehouse demo data seeded.'); } finally { diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index d8e585a35..d669e0335 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; +import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -23,6 +25,8 @@ import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.ent * Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory) * Import → Unloaded Queue : UNLOADED import inventory * Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) + * Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking + * Import → Container Returns : empty container returns at two different statuses * * Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it * never collides with other seeders. To repopulate after items are walked through their lifecycle, @@ -166,16 +170,19 @@ export class WarehouseDemoSeeder { } // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored). + let firstUnloadedBooking: Booking | null = null; for (let i = 1; i <= 3; i++) { const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); await makeInventory(b, 'UNLOADED', 5000 + i * 500, { arrivedAt: ago(90), unloadedAt: ago(45), }); + firstUnloadedBooking ??= b; created++; } // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). + let firstPickupBooking: Booking | null = null; for (let i = 1; i <= 3; i++) { const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i); await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, { @@ -185,6 +192,50 @@ export class WarehouseDemoSeeder { inspectedAt: ago(120), readyForPickupAt: ago(60), }); + firstPickupBooking ??= b; + created++; + } + + // 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking. + if (firstUnloadedBooking) { + await this.dataSource.getRepository(CustomerTruckAssignment).save( + this.dataSource.getRepository(CustomerTruckAssignment).create({ + bookingId: firstUnloadedBooking.id, + plateNumber: 'WH-DEMO-3210', + driverName: 'Demo Driver', + truckType: 'FLATBED', + assignedAt: ago(80), + arrivedAt: ago(50), + }), + ); + created++; + } + + // 7) Container Returns — two empty returns at different stages of the return workflow. + if (firstPickupBooking) { + const returnRepo = this.dataSource.getRepository(EmptyContainerReturn); + await returnRepo.save( + returnRepo.create({ + containerNumber: 'WHDU1234561', + bookingId: firstPickupBooking.id, + returnDate: ago(20), + facility: 'Indode', + status: 'RETURNED', + returnedBy: 'CUSTOMER', + statusHistory: [], + }), + ); + await returnRepo.save( + returnRepo.create({ + containerNumber: 'WHDU1234562', + bookingId: firstPickupBooking.id, + returnDate: ago(90), + facility: 'Indode', + status: 'DOCUMENTATION_CLEARED', + returnedBy: 'CUSTOMER', + statusHistory: [], + }), + ); created++; } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index feb4c4815..6f329c8d7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -5,10 +5,12 @@ import { Alert, Badge, Button, + Card, Group, Loader, Modal, SegmentedControl, + SimpleGrid, Stack, Table, Text, @@ -18,16 +20,23 @@ import { Checkbox, } from "@mantine/core"; import { ChevronDown, ChevronRight, History } from "lucide-react"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; import { PageContainer, PageHeader } from "@/components/page"; +import ListControls from "@/components/common/ListControls"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { useListControls } from "@/hooks/useListControls"; +import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; +import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; +import { useListControls, toDayString } from "@/hooks/useListControls"; import { useToast } from "@/hooks/use-toast"; import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; import { api } from "@/services/api"; import { warehouseService } from "@/services/warehouse.service"; import { importOperationsService } from "@/services/importOperations.service"; -import type { EmptyContainerReturnStatus } from "@/types/importOperations"; +import type { + EmptyContainerReturn, + EmptyContainerReturnStatus, +} from "@/types/importOperations"; type ReturnType = "all" | "edr" | "customer"; @@ -51,6 +60,13 @@ const RETURN_STATUS_LABEL: Record = { COMPLETED: "Completed", }; +// Fixed series colors (colors follow the entity, never the rank) — pair +// validated for CVD separation + surface contrast. +const RETURNED_BY_SERIES = [ + { key: "edr", label: "EDR Last Mile", color: "#0d9488" }, + { key: "customer", label: "Customer Self-Haul", color: "#b45309" }, +]; + interface ContainerReturnRow { key: string; containerNumber: string; @@ -186,10 +202,47 @@ export default function ContainerReturnsPage() { enabled: bookingIds.length > 0 && !queueLoading, }); + const [statusFilter, setStatusFilter] = useState(null); + const filteredReturnedContainers = useMemo(() => { - if (filterType === "all") return returnedContainers; - return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase()); - }, [returnedContainers, filterType]); + let rows = returnedContainers as EmptyContainerReturn[]; + if (filterType !== "all") { + rows = rows.filter((ret) => ret.returnedBy === filterType.toUpperCase()); + } + if (statusFilter) { + rows = rows.filter((ret) => ret.status === statusFilter); + } + return rows; + }, [returnedContainers, filterType, statusFilter]); + + const returnedControls = useListControls(filteredReturnedContainers, { + dateKey: "returnDate", + searchValue: (ret) => + `${ret.containerNumber} ${ret.facility ?? ""} ${ret.yard ?? ""} ${ret.condition ?? ""}`, + }); + + // Charts read the filtered set, so the controls above drive them too. + const returnsPerDay = useMemo(() => { + const byDay = new Map(); + for (const ret of returnedControls.filteredRows) { + const day = toDayString(ret.returnDate); + if (!day) continue; + const entry = byDay.get(day) ?? { date: day, edr: 0, customer: 0 }; + if (ret.returnedBy === "CUSTOMER") entry.customer += 1; + else entry.edr += 1; + byDay.set(day, entry); + } + return [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date)); + }, [returnedControls.filteredRows]); + + const returnsByStatus = useMemo( + () => + RETURN_STATUS_ORDER.map((status) => ({ + label: RETURN_STATUS_LABEL[status], + value: returnedControls.filteredRows.filter((ret) => ret.status === status).length, + })), + [returnedControls.filteredRows], + ); const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]); const filteredGroups = useMemo(() => { @@ -271,6 +324,103 @@ export default function ContainerReturnsPage() { }, }); + const returnedColumns: ColumnDef[] = [ + { + id: "containerNumber", + header: "Container Number", + cell: ({ row }) => ( + + {row.original.containerNumber} + + ), + }, + { + id: "bookingRef", + header: "Booking Ref", + cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"), + }, + { + id: "returnedBy", + header: "Returned By", + cell: ({ row }) => + row.original.returnedBy ? ( + + {row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} + + ) : ( + "—" + ), + }, + { + id: "returnDate", + header: "Returned Date", + cell: ({ row }) => + row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—", + }, + { + id: "facility", + header: "Facility", + cell: ({ row }) => row.original.facility || "—", + }, + { + id: "yard", + header: "Yard", + cell: ({ row }) => row.original.yard || "—", + }, + { + id: "condition", + header: "Condition", + cell: ({ row }) => ( + + {row.original.condition || "—"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + {RETURN_STATUS_LABEL[row.original.status] ?? row.original.status} + + ), + }, + { + id: "action", + header: "Action", + cell: ({ row }) => { + const ret = row.original; + const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; + return ( + + setHistoryRow(ret)} + title="View status history" + > + + + {nextStatus ? ( + + ) : ( + + Done + + )} + + ); + }, + }, + ]; + const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null; if (queueLoading || containerReturnsQuery.isLoading) { @@ -305,73 +455,45 @@ export default function ContainerReturnsPage() { - {filteredReturnedContainers.length > 0 && ( - <> - Returned Containers - - - - - Container Number - Booking Ref - Returned By - Returned Date - Facility - Yard - Condition - Status - Action - - - - {filteredReturnedContainers.map((ret: any) => { - const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; - return ( - - {ret.containerNumber} - {ret.bookingId ? "Associated" : "—"} - - {ret.returnedBy ? ( - - {ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} - - ) : ( - "—" - )} - - {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} - {ret.facility || "—"} - {ret.yard || "—"} - {ret.condition || "—"} - - {RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status} - - - - setHistoryRow(ret)} title="View status history"> - - - {nextStatus ? ( - - ) : ( - Done - )} - - - - ); - })} - -
-
- + {returnedContainers.length > 0 && ( + + + Returned Containers + { + returnedControls.reset(); + setStatusFilter(null); + }} + > + setDraft(e.target.value)} + /> + + + {invalid && draft !== "" && ( +

+ Enter a rate between 1 and 10,000. +

+ )} +

+ {data?.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime( + data?.lastSyncedAt ?? null, + )}).`} +

+ + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts new file mode 100644 index 000000000..2bb975b91 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts @@ -0,0 +1,40 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE; + +/** Where the rate the API last served came from. */ +export type ExchangeRateSource = "live" | "cache" | "stored" | "default"; + +/** Health of the CBE exchange-rate feed. */ +export interface ExchangeFeedStatus { + rate: number | null; + source: ExchangeRateSource | null; + lastSuccessAt: string | null; + lastError: string | null; +} + +export interface ExchangeSettings { + fallbackRate: number; + /** `AUTO` when synced from CBE, `MANUAL` when set here. */ + fallbackSource: "AUTO" | "MANUAL"; + lastSyncedAt: string | null; + updatedById: string | null; + feed?: ExchangeFeedStatus; +} + +export const exchangeSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + setFallbackRate: async (fallbackRate: number): Promise => { + const response = await client.patch>(BASE, { + fallbackRate, + }); + return unwrap(response.data); + }, +}; diff --git a/packages/api-common/src/services/exchange/cbe.provider.ts b/packages/api-common/src/services/exchange/cbe.provider.ts index 489b1a4e3..3aa4fadba 100644 --- a/packages/api-common/src/services/exchange/cbe.provider.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -1,30 +1,62 @@ import { Logger } from "@nestjs/common"; -import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; +import { + EXCHANGE_DEFAULTS, + ExchangeOptions, + ResolvedExchangeOptions, +} from "./exchange.options"; import { CurrencyPair, ExchangeRateProvider, } from "./exchange.types"; -/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ -const USD_RATE_REGEX = - /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; +/** One currency's rates within a daily record returned by the CBE endpoint. */ +interface CbeExchangeRateEntry { + transactionalSelling?: number | string | null; + transactionalBuying?: number | string | null; + currency?: { CurrencyCode?: string | null } | null; +} + +/** A single day's record from the CBE `daily-exchange-rates` endpoint. */ +interface CbeDailyRecord { + Date?: string | null; + ExchangeRate?: CbeExchangeRateEntry[] | null; +} + +/** Where the most recently served rate came from. */ +export type CbeRateSource = "live" | "cache" | "stored" | "default"; + +/** Health of the CBE feed, for operator-facing status displays. */ +export interface CbeProviderStatus { + /** The rate most recently served, whatever its source. */ + rate: number | null; + /** Where that rate came from. `live` means the API answered. */ + source: CbeRateSource | null; + /** Epoch ms of the last successful live fetch, or `null` if never. */ + lastSuccessAt: number | null; + /** Message from the most recent failed fetch, cleared on success. */ + lastError: string | null; +} /** - * Central Bank of Ethiopia (CBE) rate provider. + * Commercial Bank of Ethiopia (CBE) rate provider. * - * Sources a single canonical direction — **USD→ETB** (selling rate) — by - * scraping ethio.forex, caching the result, and falling back to a configured - * rate when the scrape fails. The inverse (ETB→USD) is derived by - * {@link ExchangeService}, so this provider only ever reports USD→ETB. + * Sources a single canonical direction — **USD→ETB** (transactional selling + * rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the + * result and falling back to a configured rate when the fetch fails. The + * inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider + * only ever reports USD→ETB. */ export class CbeExchangeProvider implements ExchangeRateProvider { readonly name = "CBE"; private readonly logger = new Logger(CbeExchangeProvider.name); - private readonly options: Required; + private readonly options: ResolvedExchangeOptions; private cachedRate: number | null = null; private cacheExpiresAt = 0; + private lastSuccessAt: number | null = null; + private lastError: string | null = null; + private lastSource: CbeRateSource | null = null; constructor(options: ExchangeOptions) { this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; @@ -38,15 +70,29 @@ export class CbeExchangeProvider implements ExchangeRateProvider { return this.getUsdToEtbRate(); } + /** Health of the CBE feed — what was served last, and whether it is failing. */ + getStatus(): CbeProviderStatus { + return { + rate: this.cachedRate, + source: this.lastSource, + lastSuccessAt: this.lastSuccessAt, + lastError: this.lastError, + }; + } + /** - * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. - * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else - * returns `fallbackRate`. + * Returns the current CBE USD→ETB **transactional selling** rate. + * + * Cached for `cacheTtlMs`. On a successful fetch the rate is written back via + * `saveFallbackRate`, so the stored fallback is never more than one good + * fetch stale. On failure the chain is: cached rate → `loadFallbackRate()` + * → static `fallbackRate`. */ private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { + this.lastSource = "cache"; return this.cachedRate; } @@ -56,68 +102,133 @@ export class CbeExchangeProvider implements ExchangeRateProvider { try { const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(requestTimeoutMs), - headers: { "User-Agent": "Mozilla/5.0" }, + headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { - throw new Error(`CBE scrape responded with status ${response.status}`); + throw new Error(`CBE rates responded with status ${response.status}`); } - const html = await response.text(); - const rates = this.parseScrapedRates(html); + const payload = (await response.json()) as unknown; + const day = this.latestRecord(payload); - if (!rates) { - throw new Error("USD rate not found in ethio.forex page HTML"); + if (!day) { + throw new Error("CBE rates payload contained no daily record"); } - const rate = rates.selling; - if (!Number.isFinite(rate) || rate <= 0) { - throw new Error(`Invalid selling rate parsed: ${rate}`); + const rate = this.parseUsdRate(day); + + if (rate === null) { + throw new Error( + `USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, + ); } + const previous = this.cachedRate; this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; + this.lastSuccessAt = now; + this.lastError = null; + this.lastSource = "live"; this.logger.log( - `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, - ); - return rate; - } catch (err) { - this.logger.error( - `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + `CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`, ); + // Persist as the new fallback so a later outage reuses the last good + // rate. Skipped when unchanged, to avoid pointless writes and audit noise. + if (rate !== previous) { + await this.persistFallback(rate); + } + + return rate; + } catch (err) { + const message = (err as Error).message; + this.lastError = message; + this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`); + if (this.cachedRate !== null) { + this.lastSource = "cache"; this.logger.warn( `Using previously cached CBE rate: ${this.cachedRate}`, ); return this.cachedRate; } + const stored = await this.loadStoredFallback(); + if (stored !== null) { + this.lastSource = "stored"; + this.logger.warn(`Using stored fallback CBE rate: ${stored}`); + return stored; + } + + this.lastSource = "default"; + this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`); return fallbackRate; } } - private parseScrapedRates( - html: string, - ): { buying: number; selling: number } | null { - const decoded = this.unescapeHtml(html); - const match = USD_RATE_REGEX.exec(decoded); - if (!match) return null; + /** + * Writes a freshly fetched rate back as the stored fallback. Failures are + * logged and swallowed: persisting the fallback is housekeeping, and must + * never fail the pricing call that triggered it. + */ + private async persistFallback(rate: number): Promise { + const { saveFallbackRate } = this.options; + if (!saveFallbackRate) return; - const buying = Number(match[1]); - const selling = Number(match[2]); - if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; - - return { buying, selling }; + try { + await saveFallbackRate(rate); + } catch (err) { + this.logger.warn( + `Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`, + ); + } } - private unescapeHtml(html: string): string { - return html - .replace(/"/g, '"') - .replace(/"/g, '"') - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">"); + /** + * Reads the persisted fallback. Returns `null` — falling through to the + * static default — when unconfigured, unusable, or itself failing. + */ + private async loadStoredFallback(): Promise { + const { loadFallbackRate } = this.options; + if (!loadFallbackRate) return null; + + try { + const stored = await loadFallbackRate(); + const rate = Number(stored); + return Number.isFinite(rate) && rate > 0 ? rate : null; + } catch (err) { + this.logger.warn( + `Failed to load stored CBE fallback rate: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * The endpoint returns an array of daily records (one when `_limit=1`), but + * tolerate a bare object in case the shape changes. + */ + private latestRecord(payload: unknown): CbeDailyRecord | null { + const record = Array.isArray(payload) ? payload[0] : payload; + return record && typeof record === "object" + ? (record as CbeDailyRecord) + : null; + } + + /** + * Pulls USD `transactionalSelling` out of a daily record. Returns `null` when + * the entry is missing or the value isn't a usable positive number — CBE + * publishes `0`/`null` for currencies it isn't quoting that day. + */ + private parseUsdRate(day: CbeDailyRecord): number | null { + const usd = day.ExchangeRate?.find( + (entry) => entry?.currency?.CurrencyCode === "USD", + ); + if (!usd) return null; + + const rate = Number(usd.transactionalSelling); + return Number.isFinite(rate) && rate > 0 ? rate : null; } } diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts index e009b5f99..6ccf83489 100644 --- a/packages/api-common/src/services/exchange/exchange.options.ts +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -4,18 +4,39 @@ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); /** Configuration for the {@link ExchangeService} and its CBE provider. */ export interface ExchangeOptions { /** - * ethio.forex CBET page scraped for USD buying/selling rates. - * @default 'https://ethio.forex/bank/CBET' + * CBE daily-exchange-rates JSON endpoint. Returns an array of daily records; + * `_limit=1&_sort=Date%3ADESC` narrows it to the most recent day. + * @default 'https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC' */ scrapeUrl?: string; /** - * Base USD→ETB rate used when scraping fails and no previously cached rate - * exists. The ETB→USD direction is derived as its inverse. - * @default 130 + * Last-resort USD→ETB rate, used only when the fetch fails, no cached rate + * exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD + * direction is derived as its inverse. + * @default 162 */ fallbackRate?: number; + /** + * Reads the persisted fallback rate — the last known good CBE rate, or one + * set by an operator. Consulted only when the live fetch fails and no cached + * rate is available; a `null` result falls through to {@link fallbackRate}. + * + * Optional: omit it and the provider uses the static `fallbackRate` alone. + */ + loadFallbackRate?: () => Promise; + + /** + * Persists a freshly fetched live rate as the new fallback, so the stored + * value is never more than one successful fetch stale. Called after every + * successful fetch that produced a changed rate. + * + * Failures here are logged and swallowed — persisting the fallback must + * never break the pricing call that triggered it. + */ + saveFallbackRate?: (rate: number) => Promise; + /** * How long a successfully fetched rate is cached, in milliseconds. * @default 3_600_000 (1 hour) @@ -23,16 +44,23 @@ export interface ExchangeOptions { cacheTtlMs?: number; /** - * Timeout for the scrape HTTP request, in milliseconds. + * Timeout for the rate HTTP request, in milliseconds. * @default 8_000 */ requestTimeoutMs?: number; } -/** Defaults applied to any unset {@link ExchangeOptions} field. */ -export const EXCHANGE_DEFAULTS: Required = { - scrapeUrl: "https://ethio.forex/bank/CBET", - fallbackRate: 130, +/** The scalar options, all resolved — the callbacks stay genuinely optional. */ +export type ResolvedExchangeOptions = Required< + Omit +> & + Pick; + +/** Defaults applied to any unset scalar {@link ExchangeOptions} field. */ +export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = { + scrapeUrl: + "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC", + fallbackRate: 162, cacheTtlMs: 3_600_000, requestTimeoutMs: 8_000, }; diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts index c1efdd923..135bafb6d 100644 --- a/packages/api-common/src/services/exchange/exchange.service.ts +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from "@nestjs/common"; -import { CbeExchangeProvider } from "./cbe.provider"; +import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider"; import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; import { CurrencyCode } from "./exchange.types"; @@ -47,6 +47,14 @@ export class ExchangeService { ); } + /** + * Health of the underlying rate feed — what was served last and whether it + * is currently failing. For operator-facing status displays. + */ + getProviderStatus(): CbeProviderStatus { + return this.provider.getStatus(); + } + /** Converts `amount` from one currency to another using {@link getRate}. */ async convert( amount: number, diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts index c5e9c960a..f2f88891a 100644 --- a/packages/api-common/src/services/exchange/index.ts +++ b/packages/api-common/src/services/exchange/index.ts @@ -1,8 +1,13 @@ export { ExchangeService } from "./exchange.service"; export { ExchangeModule } from "./exchange.module"; export { CbeExchangeProvider } from "./cbe.provider"; +export type { CbeProviderStatus, CbeRateSource } from "./cbe.provider"; export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; -export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options"; +export type { + ExchangeOptions, + ExchangeAsyncOptions, + ResolvedExchangeOptions, +} from "./exchange.options"; export type { CurrencyCode, CurrencyPair, From 45216c562478b33655c832372875d697f2080583 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 11:40:08 +0000 Subject: [PATCH 4/4] fix: update payment currency handling to require explicit selection by customer --- .../src/pages/contracts/NewShipmentPage.tsx | 25 +++++++++--- .../new-shipment-form/currency.test.ts | 40 +++++++++++++++++++ .../contracts/new-shipment-form/schema.ts | 16 ++++++-- 3 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 6b2ce4470..33a1343a2 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -265,8 +265,10 @@ function NewShipmentBookingForm({ // still flip it per shipment. withReturn: contract.equipmentReturn === "WITH_RETURN", // The contract quotes USD; the customer bills this shipment in the - // currency they pick here. Intercity is always ETB. - paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD", + // currency they pick here. Intercity is always ETB, so it is preset; + // everything else starts empty so the customer picks deliberately + // instead of silently inheriting USD. + paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "", }, resolver: zodResolver( createShipmentFormSchema({ @@ -340,7 +342,11 @@ function NewShipmentBookingForm({ ...(values.contractRouteId ? { contractRouteId: values.contractRouteId } : {}), - paymentCurrency: values.paymentCurrency, + // Validation guarantees a currency by here; the guard keeps an empty + // value out of the payload rather than tripping the API's @IsIn check. + ...(values.paymentCurrency + ? { paymentCurrency: values.paymentCurrency } + : {}), // Intercity bookings carry no date — staff assign a passing train later. ...(values.scheduledDate ? { scheduledDate: new Date(values.scheduledDate).toISOString() } @@ -1131,23 +1137,32 @@ function ScheduleStep({ ( + render={({ field, fieldState }) => ( Billing currency * Your contract is quoted in USD. Pick the currency this shipment is invoiced in — the total is converted for you. + {/* Rendered unselected until the customer chooses: SegmentedControl + highlights whatever value it is given, so passing a fallback + here would look like a made choice. */} field.onChange(v)} data={[ + { label: "Select…", value: "", disabled: true }, { label: "USD", value: "USD" }, { label: "ETB", value: "ETB" }, ]} color="edr-green" radius={10} /> + {fieldState.error && ( + + {fieldState.error.message} + + )} )} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts new file mode 100644 index 000000000..ca818659c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/currency.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { createShipmentFormSchema, initialShipmentFormValues } from "./schema"; + +const schema = createShipmentFormSchema({ + isContainer: false, + isHazardous: false, + isReefer: false, + requiresDate: false, +}); + +const values = (over: Record = {}) => ({ + ...initialShipmentFormValues, + cargoWeightTons: "10", + ...over, +}); + +const currencyIssues = (input: Record) => { + const result = schema.safeParse(input); + return result.success + ? [] + : result.error.issues.filter((i) => i.path[0] === "paymentCurrency"); +}; + +describe("paymentCurrency validation", () => { + it("defaults to empty rather than silently picking USD", () => { + expect(initialShipmentFormValues.paymentCurrency ?? "").toBe(""); + }); + + it("rejects a submit with no currency chosen", () => { + const issues = currencyIssues(values({ paymentCurrency: "" })); + expect(issues).toHaveLength(1); + expect(issues[0].message).toBe("Select the billing currency for this shipment."); + }); + + it("accepts either currency once chosen", () => { + expect(currencyIssues(values({ paymentCurrency: "USD" }))).toHaveLength(0); + expect(currencyIssues(values({ paymentCurrency: "ETB" }))).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 539296f5d..825112980 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -76,8 +76,9 @@ const shipmentFormBase = z.object({ // EXPORT rail: the specific train picked for the shipment day (schedule id). trainScheduleId: z.string().default(""), // The contract quotes in USD; the customer picks the billing currency for - // THIS shipment. Intercity is forced to ETB (server-enforced too). - paymentCurrency: z.enum(["USD", "ETB"]).default("USD"), + // THIS shipment. Starts empty so the choice is deliberate — validated as + // required below. Intercity is forced to ETB (server-enforced too). + paymentCurrency: z.enum(["USD", "ETB", ""]).default(""), // Container contracts only: return the empty container(s) to EDR after // unloading. Seeded from the contract's equipment return; bulk ignores it. withReturn: z.boolean().default(false), @@ -101,6 +102,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { }); } + // No default currency — the customer must pick one before submitting. + if (!data.paymentCurrency) { + refineCtx.addIssue({ + code: "custom", + path: ["paymentCurrency"], + message: "Select the billing currency for this shipment.", + }); + } + if (ctx.isContainer) { // Containerized cargo must say WHAT is inside — required per booking. if (!data.cargoDescription.trim()) { @@ -311,6 +321,6 @@ export const shipmentStepFields: Record< "bulkReeferQuantity", "withReturn", ], - 2: ["scheduledDate"], + 2: ["paymentCurrency", "scheduledDate"], 3: ["notes"], };