From 3a2f1a46d6edb4db7736d9143cfb63fe25116a2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 13:24:42 +0000 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 9a50df2be30e34f390dea3202798dafc43651fe3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:05:13 +0000 Subject: [PATCH 05/20] 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 06/20] 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 07/20] 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 c61b66fd235fe1ce44a2fd8126dbe467fa501756 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:40:28 +0000 Subject: [PATCH 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 13/20] 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" + /> +