From 50fab52b1c8f4a080ff5c438eacdfdaa4a9fb143 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 4 Aug 2026 09:54:15 +0000 Subject: [PATCH 01/16] 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 03/16] 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"], }; From 3a5126670ff71398c9141b8e6896b84711ff85ae Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 4 Aug 2026 15:32:49 +0300 Subject: [PATCH 04/16] fix: centralized on rabbitmq for sms --- .../strategies/notification.sms.strategy.ts | 58 +++---------------- .../src/modules/otp/otp.service.ts | 8 +-- 2 files changed, 13 insertions(+), 53 deletions(-) diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index cddc67b2d..ac021c322 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -1,62 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import axios, { isAxiosError } from "axios"; import { NotificationStrategy } from "./notification.strategy"; +import { SmsClientService } from "../sms-client.service"; @Injectable() export class SmsNotificationStrategy implements NotificationStrategy { private readonly logger = new Logger(SmsNotificationStrategy.name); - constructor(private readonly configService: ConfigService) {} + constructor(private readonly smsClient: SmsClientService) {} async send(recipient: string, message: string): Promise { - const url = - this.configService.get("OZIKING_SMS_URL") ?? - "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms"; - - const appKey = this.configService.get("OZIKING_APP_KEY") ?? ""; - if (!appKey) { - this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API"); - } - - this.logger.debug(`Sending SMS to ${recipient} via ${url}`); - - // axios defaults to no timeout — a hanging gateway would block the caller - // (and any transaction it sits in) indefinitely. Always bound the wait. - const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); - - try { - const response = await axios.post( - url, - { - to: recipient, - sourceId: this.configService.get("OZIKING_SOURCE_ID") ?? "EDR", - sourceName: this.configService.get("OZIKING_SOURCE_NAME") ?? "EDR Freight", - appKey, - text: message, - callbackUrl: "", - }, - { - timeout, - headers: { - accept: "*/*", - "Content-Type": "application/json", - }, - }, - ); - - this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`); - return true; - } catch (err) { - if (isAxiosError(err)) { - this.logger.error( - `SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`, - ); - } else { - this.logger.error(`SMS send failed: ${String(err)}`); - } - throw err; + const { queued } = await this.smsClient.sendSms({ + to: recipient, + message, + }); + if (!queued) { + this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`); } + return queued; } } diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index d5688e1c2..a7361fbdd 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -265,10 +265,10 @@ export class OtpService { /** * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent - * via NotificationsService's direct-HTTP Ozeking strategy — the same - * transport the notification system uses — rather than the RabbitMQ - * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the - * request, not just that a broker took ownership of the message. + * via NotificationsService's `directSend`, which now routes through the + * same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so + * `queued: true` here means the broker confirmed ownership of the message, + * not that the carrier delivered it. */ private async dispatchSms( phone: string, From 53accbc57b52f9f35aeade326852c57765bac262 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 10:52:49 +0000 Subject: [PATCH 05/16] feat(freight-portal): verify Fayda via redirect, gate DARS on verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification popup was opened after the /start round-trip, by which point the click's user activation is spent — iOS Safari blocks it outright, so mobile customers could never verify. Replace the popup with a full-page redirect: the panel stashes {subject, returnTo} in sessionStorage and navigates the tab to eSignet, and /callback completes the code+state exchange itself before returning the user where they were. This drops the postMessage listener, the popup-closed poller and the pop-up-blocked branch. onVerified goes with them: the app boots fresh on the way back, so the target page refetches rather than being pushed to. Hide the DARS delegation upload until the PoA is Fayda-verified. The paper authorises the representative the verification names, so it has nothing to authorise before one exists — and it has to stop being required while hidden, or the save blocks on a control the customer cannot see. Freight forwarders are still held to having a PoA by the step's verification gate and by the API. This also removes the one thing the redirect could not carry across: a staged File cannot be serialised to sessionStorage, and there is now never one pending before verification. Unsaved text typed since the last step-save is still lost on redirect; the wizard's per-step persistence covers everything already advanced past. --- .../src/components/FaydaVerifyPanel.tsx | 109 +++--------------- .../onboarding/OnboardingWizardDialog.tsx | 4 - .../portal/src/pages/FaydaCallbackPage.tsx | 94 ++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 19 ++- .../src/pages/settings/TabCompanyProfile.tsx | 5 - .../src/pages/settings/TabPowerOfAttorney.tsx | 19 +-- .../src/services/verifayda.pending.test.ts | 44 +++++++ .../portal/src/services/verifayda.service.ts | 46 ++++++-- 8 files changed, 183 insertions(+), 157 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/services/verifayda.pending.test.ts diff --git a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx index b07d7352c..8ecb87854 100644 --- a/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx +++ b/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Alert, Avatar, @@ -20,9 +20,8 @@ import { } from "lucide-react"; import { + stashPendingVerification, verifaydaService, - type CompanyIdentityState, - type FaydaCallbackMessage, type IdentitySubject, type IdentityVerificationState, } from "@/services/verifayda.service"; @@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps { * on it, so the panel says so rather than nagging. */ required: boolean; - /** Called with the fresh company-wide state once a verification lands. */ - onVerified: (next: CompanyIdentityState) => void; disabled?: boolean; /** * True when a fresh verification for this person is already staged in a @@ -61,109 +58,39 @@ function getInitials(name: string | null): string { /** * Verify one of the company's people through Fayda and show what came back. * - * The identity is proved in an eSignet popup; that popup lands on /callback, - * which relays the code+state here by postMessage. This window then completes - * the exchange — once, in one place — and the API writes the person's name, - * phone, email and address from the verified payload. Nothing on this panel - * is typed. + * The identity is proved on eSignet, which the whole tab navigates to — no + * popup, because a popup opened after the /start round-trip has lost its user + * activation and iOS Safari blocks it outright. eSignet redirects back to + * /callback, which completes the exchange and returns the user here; the API + * writes the person's name, phone, email and address from the verified + * payload. Nothing on this panel is typed. */ export default function FaydaVerifyPanel({ subject, title, state, required, - onVerified, disabled, pendingReview, }: FaydaVerifyPanelProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // The listener closes over `subject`; keep it in a ref so remounting the - // panel between steps can't complete a verification against the wrong person. - const subjectRef = useRef(subject); - subjectRef.current = subject; - // FaydaCallbackPage posts its message from a StrictMode-double-invoked - // effect in dev, so the same one-time-use code+state can arrive twice. - // Track the last state we've started completing so the resend is a no-op. - const handledStateRef = useRef(null); - // Polls the popup so a manually-closed window (no postMessage ever sent) - // still clears `loading` instead of leaving the button spinning forever. - const pollRef = useRef(null); - - const stopPolling = () => { - if (pollRef.current !== null) { - window.clearInterval(pollRef.current); - pollRef.current = null; - } - }; - - useEffect(() => { - const onMessage = async (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; - if (event.data?.type !== "fayda-callback") return; - - if (event.data.error) { - stopPolling(); - setLoading(false); - setError(event.data.errorDescription ?? event.data.error); - return; - } - if (!event.data.code || !event.data.state) return; - if (handledStateRef.current === event.data.state) return; - handledStateRef.current = event.data.state; - stopPolling(); - - try { - const next = await verifaydaService.completeIdentity( - subjectRef.current, - event.data.code, - event.data.state, - ); - setError(null); - onVerified(next); - } catch (err) { - setError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error ? err.message : "Verification failed"), - ); - } finally { - setLoading(false); - } - }; - window.addEventListener("message", onMessage); - return () => { - window.removeEventListener("message", onMessage); - stopPolling(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); const startVerification = async () => { setError(null); setLoading(true); - handledStateRef.current = null; try { const authorizationUrl = await verifaydaService.start(); - const popup = window.open( - authorizationUrl, - "fayda-verify", - "width=480,height=760,noopener=no", - ); - if (!popup) { - setLoading(false); - setError("Pop-up blocked — allow pop-ups for this site and try again."); - return; - } - // Loading stays on until the popup posts back — unless the user closes - // it by hand, which never sends a message; poll for that and clear - // loading ourselves so the button doesn't spin forever. - stopPolling(); - pollRef.current = window.setInterval(() => { - if (!popup.closed) return; - stopPolling(); - if (handledStateRef.current === null) setLoading(false); - }, 500); + // Record who is being verified and where to come back to before the tab + // leaves — /callback has no other way to know either. + stashPendingVerification({ + subject, + returnTo: + window.location.pathname + + window.location.search + + window.location.hash, + }); + window.location.assign(authorizationUrl); } catch (err) { setLoading(false); setError( diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 466307fe9..d75b07412 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -437,10 +437,6 @@ export default function OnboardingWizardDialog({ // stays a plain typed role. Mandatory (Fayda) for an Ethiopian company; // a foreign one requires a typed passport number for the owner instead. identity: requirementsQuery.data?.identity, - onIdentityChange: () => { - void profileQuery.refetch(); - void requirementsQuery.refetch(); - }, // Surface a failed final submit (license/document upload or complete) inside // the form — otherwise the server message (e.g. a 500) would be invisible on // the submit step. diff --git a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx index c25dc836a..16e7d43ac 100644 --- a/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx @@ -1,51 +1,89 @@ -import { useEffect, useState } from "react"; -import { Center, Loader, Stack, Text } from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Center, Loader, Stack, Text } from "@mantine/core"; -import type { FaydaCallbackMessage } from "@/services/verifayda.service"; +import { + takePendingVerification, + verifaydaService, +} from "@/services/verifayda.service"; /** * Landing page for the portal's eSignet redirect_uri - * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the - * verification popup: relays ?code&state (or ?error) to the window that opened - * it via postMessage, then closes itself. The opener performs the completion - * call so the single-use session is only consumed once, in one place. + * (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). + * + * The verification is a full-page redirect, so the page that started it no + * longer exists: this page completes the code+state exchange itself against + * the subject FaydaVerifyPanel stashed, then sends the user back where they + * were. Everything mounts fresh on the way back, so the verified identity is + * fetched rather than pushed. */ export default function FaydaCallbackPage() { - const [standalone, setStandalone] = useState(false); + const navigate = useNavigate(); + const [error, setError] = useState(null); + const [returnTo, setReturnTo] = useState("/"); + // The code+state are single-use, so StrictMode's double-invoked effect must + // not exchange them twice — the second attempt would fail on a spent session. + const startedRef = useRef(false); useEffect(() => { - const params = new URLSearchParams(window.location.search); - const message: FaydaCallbackMessage = { - type: "fayda-callback", - code: params.get("code") ?? undefined, - state: params.get("state") ?? undefined, - error: params.get("error") ?? undefined, - errorDescription: params.get("error_description") ?? undefined, - }; + if (startedRef.current) return; + startedRef.current = true; - if (window.opener && window.opener !== window) { - (window.opener as Window).postMessage(message, window.location.origin); - window.close(); - } else { - // Opened as a full-page redirect instead of a popup — nothing to relay to. - setStandalone(true); + const params = new URLSearchParams(window.location.search); + const pending = takePendingVerification(); + if (pending) setReturnTo(pending.returnTo); + + const authError = params.get("error"); + if (authError) { + setError(params.get("error_description") ?? authError); + return; } - }, []); + + const code = params.get("code"); + const state = params.get("state"); + if (!code || !state) { + setError("This verification link is missing its code — start again."); + return; + } + if (!pending) { + // Landed here without the tab that started it — a bookmarked/copied + // callback URL, or sessionStorage cleared mid-flow. + setError("This verification was started somewhere else — start again."); + return; + } + + verifaydaService + .completeIdentity(pending.subject, code, state) + .then(() => navigate(pending.returnTo, { replace: true })) + .catch((err) => + setError( + (err as { response?: { data?: { message?: string } } })?.response + ?.data?.message ?? + (err instanceof Error ? err.message : "Verification failed"), + ), + ); + }, [navigate]); return (
- {standalone ? ( + {error ? ( <> - Verification window lost its parent page - - Close this tab and start the verification again from the form. + Verification could not be completed + + {error} + ) : ( <> - + Completing Fayda verification… diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 0dc02eaa4..a122b5440 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -67,7 +67,6 @@ export default function CompanyProfileForm({ uploadedDocumentKeys, onUploadDocuments, identity, - onIdentityChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -107,8 +106,6 @@ export default function CompanyProfileForm({ >; /** Fayda verification state for the owner and the PoA (undefined until loaded). */ identity?: CompanyIdentityState; - /** Refetch the profile + requirements once a verification lands. */ - onIdentityChange?: () => void; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -535,14 +532,16 @@ export default function CompanyProfileForm({ const currentIdx = stepOrder.indexOf(step); // The DARS delegation paper is what proves the representative was actually - // delegated, so it's required the moment a PoA exists — and unconditionally - // for a freight forwarder, whose PoA itself is mandatory. The API enforces - // the same rule on save, so skipping it here only costs the customer a + // delegated, so it's required the moment a PoA exists. The API enforces the + // same rule on save, so skipping it here only costs the customer a // round-trip. // A PoA exists exactly when one has been verified — the details are the // verification's output, so there is nothing else that could stand for one. + // Until then the upload is hidden: there is no representative for the paper + // to authorise, and a freight forwarder is held on the verification gate + // below rather than on a file field it cannot yet fill. const poaProvided = identity?.poa.verified ?? false; - const delegationRequired = requirePoa || poaProvided; + const delegationRequired = poaProvided; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -714,7 +713,6 @@ export default function CompanyProfileForm({ title="Owner" state={identity.owner} required={identity.faydaRequired} - onVerified={() => onIdentityChange?.()} /> {identity.passportRequired && ( onIdentityChange?.()} /> )} {/* The city is the one field the Fayda address claim does not @@ -884,7 +881,9 @@ export default function CompanyProfileForm({ {...register("poaLocation")} /> - {poaDocumentSetting && ( + {/* The paper authorises the representative the verification + named, so it only has meaning once one exists. */} + {poaProvided && poaDocumentSetting && ( <> - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }) - } /> {identity.passportRequired && ( {/* ------------------------ Delegation letter ------------------------ */} + {/* The paper authorises the representative the verification named, + so it only has meaning once one exists. */} + {poaProvided && ( @@ -429,6 +429,7 @@ export default function TabPowerOfAttorney({ }} /> + )} { + // The suite runs in node, not jsdom — a Map is all these two calls need. + beforeEach(() => { + const store = new Map(); + vi.stubGlobal("sessionStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }); + }); + + it("round-trips and clears, so a spent code can't be replayed", () => { + stashPendingVerification({ subject: "poa", returnTo: "/settings?tab=poa" }); + + expect(takePendingVerification()).toEqual({ + subject: "poa", + returnTo: "/settings?tab=poa", + }); + expect(takePendingVerification()).toBeNull(); + }); + + it("returns null rather than throwing on missing or malformed entries", () => { + expect(takePendingVerification()).toBeNull(); + + sessionStorage.setItem("fayda-pending-verification", "not json"); + expect(takePendingVerification()).toBeNull(); + + sessionStorage.setItem("fayda-pending-verification", '{"returnTo":"/"}'); + expect(takePendingVerification()).toBeNull(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/services/verifayda.service.ts b/apps/edr-freight-web/portal/src/services/verifayda.service.ts index 54a7084dd..c7521adda 100644 --- a/apps/edr-freight-web/portal/src/services/verifayda.service.ts +++ b/apps/edr-freight-web/portal/src/services/verifayda.service.ts @@ -38,20 +38,46 @@ export interface CompanyIdentityState { complete: boolean; } -/** Message posted from the /callback popup back to the opener window. */ -export interface FaydaCallbackMessage { - type: "fayda-callback"; - code?: string; - state?: string; - error?: string; - errorDescription?: string; +/** + * What the panel was doing when it handed the tab over to eSignet. The + * verification is a full-page redirect, so the page that started it is gone by + * the time /callback runs — this is how /callback knows whose identity the + * code+state belongs to and where to put the user back. + * + * sessionStorage, not localStorage: it is scoped to this tab, so two tabs + * verifying different people can't overwrite each other, and it dies with the + * tab rather than outliving an abandoned verification. + */ +const PENDING_KEY = "fayda-pending-verification"; + +export interface PendingVerification { + subject: IdentitySubject; + /** Path to return to once the verification completes. */ + returnTo: string; +} + +export function stashPendingVerification(pending: PendingVerification): void { + sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)); +} + +/** Read and clear — the code+state are single-use, so a retry needs a fresh start. */ +export function takePendingVerification(): PendingVerification | null { + const raw = sessionStorage.getItem(PENDING_KEY); + sessionStorage.removeItem(PENDING_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as PendingVerification; + return parsed.subject ? parsed : null; + } catch { + return null; + } } export const verifaydaService = { /** - * Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the - * portal's own registered redirect_uri — the backoffice and mobile clients - * have their own. + * Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects + * the portal's own registered redirect_uri — the backoffice and mobile + * clients have their own. */ start: async (): Promise => { const response = await client.post< From b3564338865395ff94ec4d679ccc09f389d4d0d8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 10:58:37 +0000 Subject: [PATCH 06/16] test(freight-e2e): fix stale onboarding selectors, tighten Fayda assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding journey had been failing at the company step for a while: that step was restructured into StepSection cards, so its TIN and VAT fields no longer have