From 4b07ff328dfbee7de24d0c9ebef9258925a2a10e Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 20 Aug 2026 06:57:57 +0000 Subject: [PATCH] View manual (offline) payment channel settings Confirm offline (bank transfer) invoice payment --- .../bookings/booking-transition.service.ts | 77 ++++++++++++------- .../bookings/clearance-documents-open.spec.ts | 47 +++++++++++ .../src/modules/bookings/clearance.util.ts | 33 ++++++++ .../contracts/booking-clearance.service.ts | 14 +++- .../bookings/DocumentClearanceDetailPage.tsx | 28 ++----- .../contracts/ContractClearanceListPage.tsx | 26 +++++-- .../backoffice/src/types/booking.ts | 2 + .../bookings/clearance/ClearanceFlow.tsx | 11 +++ .../bookings/clearance/useClearanceFlow.ts | 8 +- packages/types/src/freight/index.ts | 6 ++ 10 files changed, 196 insertions(+), 56 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.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 0af0a657f..805b2216f 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 @@ -27,7 +27,10 @@ import { BookingPricingService } from './booking-pricing.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; -import { clearanceCodesForBooking } from './clearance.util'; +import { + clearanceCodesForBooking, + clearanceDocumentsOpen, +} from './clearance.util'; import { buildClearanceDocHistory, type ClearanceDocEvent, @@ -639,6 +642,7 @@ export class BookingTransitionService { history: ClearanceDocEvent[]; }>; allApproved: boolean; + documentsOpen: boolean; phase?: string | null; milestones?: unknown[]; nextAction?: unknown; @@ -758,6 +762,7 @@ export class BookingTransitionService { outputCode, documents, allApproved, + documentsOpen: clearanceDocumentsOpen(booking), }; } @@ -800,10 +805,14 @@ export class BookingTransitionService { userId?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, [ - "AWAITING_DOCUMENTS", - "DOCUMENTS_UNDER_REVIEW", - ]); + // Documents stay open until the shipment is paid — a customs shipment keeps + // collecting paperwork (amended invoices, port documents) well past + // clearance finalization. See {@link clearanceDocumentsOpen}. + if (!clearanceDocumentsOpen(booking)) { + throw new ConflictException( + `Clearance documents are closed for this booking (status "${booking.status}").`, + ); + } const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { throw new BadRequestException( @@ -841,19 +850,29 @@ export class BookingTransitionService { }); } - await this.bookingsRepository.update(bookingId, { - status: "DOCUMENTS_UNDER_REVIEW", - } as never); + // Only the pre-finalization submission drives the booking into review. + // A later addition (an amended invoice while the shipment is already + // scheduled) must never rewind the status or reopen the phased workflow — + // it lands as a new PENDING document for GL to approve where it stands. + const inDocumentPhase = + booking.status === "AWAITING_DOCUMENTS" || + booking.status === "DOCUMENTS_UNDER_REVIEW"; - if (this.isPhasedCustoms(booking)) { - await this.workflowService.onCustomerDocsUploadedForBooking( - bookingId, - booking.tradeDirection ?? 'IMPORT', - ); - await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + if (inDocumentPhase) { await this.bookingsRepository.update(bookingId, { - clearanceCurrentPhase: ContractDocPhase.GlEtReview, + status: "DOCUMENTS_UNDER_REVIEW", } as never); + + if (this.isPhasedCustoms(booking)) { + await this.workflowService.onCustomerDocsUploadedForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } } const fileKeys = files.map((f) => f.fieldname); @@ -918,7 +937,14 @@ export class BookingTransitionService { note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); + // GL keeps reviewing for as long as the customer can still submit — the + // two sides share one predicate so they can never drift apart. Documents + // added after clearance was finalized still need approving/querying. + if (!clearanceDocumentsOpen(booking)) { + throw new ConflictException( + `Clearance documents are closed for this booking (status "${booking.status}").`, + ); + } const { inputCode, outputCode } = clearanceCodesForBooking(booking); const existing = @@ -935,15 +961,6 @@ export class BookingTransitionService { "A note is required when querying a document", ); } - if ( - status === 'QUERIED' && - this.isPhasedCustoms(booking) && - booking.preClearanceFinalizedAt - ) { - throw new BadRequestException( - 'Customer documents cannot be queried after pre-clearance is finalized.', - ); - } await this.bookingsRepository.setDocumentReviewStatus( bookingId, @@ -970,7 +987,10 @@ export class BookingTransitionService { "CHANGES_REQUESTED", staffId, ); - if (this.isPhasedCustoms(booking)) { + // Reopening the review phase only makes sense while clearance is still + // being decided. Querying a document that arrived afterwards must not + // drag a finalized shipment back into the GL review phase. + if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) { await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtReview, @@ -982,7 +1002,10 @@ export class BookingTransitionService { if (status === "QUERIED") { this.notifier.documentQueried(updated, fileKey, note ?? ''); } - if (this.isPhasedCustoms(updated)) { + // Same reasoning as the query branch: advance the workflow only while + // clearance is still open. Approving a late-added document leaves an + // already-finalized shipment's phase exactly where it is. + if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { await this.workflowService.onAllDocsApprovedForBooking(bookingId); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.ts new file mode 100644 index 000000000..893b7c735 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance-documents-open.spec.ts @@ -0,0 +1,47 @@ +import { Booking } from './entities/booking.entity'; +import { clearanceDocumentsOpen } from './clearance.util'; + +/** + * The customer may attach clearance documents — and GL may review them — right + * up to payment, not merely until clearance is finalized. Both the upload and + * the review endpoint gate on this one predicate, so a drift here silently + * desynchronizes the two sides. + */ +const booking = (patch: Partial): Booking => + ({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking; + +describe('clearanceDocumentsOpen', () => { + it('stays open across the whole pre-payment flow', () => { + for (const status of [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'OPERATION_REQUEST_PENDING', + 'SELECTED_FOR_BATCH', + 'PNR_GENERATED', + 'PAYMENT_VERIFICATION_IN_PROGRESS', + ]) { + expect(clearanceDocumentsOpen(booking({ status }))).toBe(true); + } + }); + + it('closes once the shipment is paid or finished', () => { + for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) { + expect(clearanceDocumentsOpen(booking({ status }))).toBe(false); + } + }); + + it('closes on a dead booking', () => { + for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) { + expect(clearanceDocumentsOpen(booking({ status }))).toBe(false); + } + }); + + it('closes when payment settled before the status caught up', () => { + expect( + clearanceDocumentsOpen( + booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }), + ), + ).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index c7fc71449..56a73fa58 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -113,3 +113,36 @@ export function clearanceCodesForBooking(booking: Booking): { includesCustoms, }; } + +/** + * Statuses after which clearance documents are closed: the shipment is paid + * and moving. Everything before that — review, clearance ready, operation + * request, batch selection, PNR, payment verification — still accepts new + * customer documents and still lets GL review them. + */ +const CLEARANCE_DOCS_CLOSED_STATUSES = new Set([ + 'PAID', + 'IN_TRANSIT', + 'ARRIVED', + 'COMPLETED', + 'REJECTED', + 'CANCELLED', + 'EXPIRED', +]); + +/** + * True while the customer may still attach clearance documents and GL may + * still approve or query them. + * + * Clearance finalization is NOT the cut-off: a customs shipment keeps + * collecting paperwork (amended invoices, revised packing lists, port + * documents) right up to the final invoice being settled. Both the customer's + * upload endpoint and GL's review endpoint gate on this one predicate, so the + * two sides can never drift apart. + */ +export function clearanceDocumentsOpen(booking: Booking): boolean { + if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false; + // Payment settled ahead of the status transition (webhook ordering). + if (booking.paymentStatus === 'PAID') return false; + return true; +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 78f6a3970..a128d194f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -40,6 +40,7 @@ import { type ClearanceDocEvent, } from '../bookings/clearance-doc-history.util'; import { ClearanceEventService } from '../bookings/clearance-event.service'; +import { clearanceDocumentsOpen } from '../bookings/clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -64,6 +65,7 @@ export interface BookingClearanceView { history: ClearanceDocEvent[]; }>; allApproved: boolean; + documentsOpen: boolean; phase?: string | null; milestones?: Array<{ id: string; @@ -354,6 +356,7 @@ export class BookingClearanceService { outputCode, documents, allApproved, + documentsOpen: clearanceDocumentsOpen(booking), phase, milestones: milestones.map((m) => ({ id: m.id, @@ -1161,7 +1164,16 @@ export class BookingClearanceService { for (const b of candidates) { if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); - if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); + if (!belongsOnEtClearanceQueue(milestones)) continue; + // Surfaced on the queue row: every required document is approved even + // though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize. + (b as Booking & { allDocsApproved?: boolean }).allDocsApproved = + milestones.some( + (m) => + m.milestoneCode === 'DOCUMENTS_APPROVED' && + (m.status === 'COMPLETED' || m.status === 'SKIPPED'), + ); + filtered.push(b); } const rows = await this.attachContractSummary(filtered); return this.narrowToYardScope(rows, user); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 39a0e9d77..555b0009e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -149,24 +149,12 @@ export default function DocumentClearanceDetailPage() { (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), )[0]?.note ?? null; - const docsPhaseComplete = - clearance?.milestones?.some( - (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", - ) ?? false; - // Querying a document is only possible while the booking is actually in - // review — the server enforces exactly that (reviewDocument asserts - // DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could - // only ever produce a 400. - // - // `preClearanceFinalized` alone was not enough: it is a phased-customs field, - // so a non-customs booking (self-clearance, and every shipping-line booking) - // never sets it and kept offering Query after Operations had finalized. - const queriesLocked = - Boolean( - (clearance as Freight.ContractClearanceView | undefined) - ?.preClearanceFinalized, - ) || - (booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW"); + // Documents stay reviewable for as long as the customer can still submit + // them — until the shipment is paid, not merely until clearance is + // finalized. `documentsOpen` is the server's own predicate (the same one + // both the upload and review endpoints gate on), so the buttons are shown + // exactly when the API would accept them. + const documentsClosed = clearance?.documentsOpen === false; const workflowFiles = (clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? []; @@ -346,8 +334,8 @@ export default function DocumentClearanceDetailPage() { void refetch()} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 503e6dc60..244f3bf6d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -164,6 +164,7 @@ export default function ContractClearanceListPage() { tradeDirection: b.tradeDirection ?? "—", freightType: b.freightType ?? "—", status: b.status, + allDocsApproved: Boolean(b.allDocsApproved), requested: requestedByBooking.get(b.id) ?? null, contractId: b.contractId ?? null, contractReference: b.contractReference ?? null, @@ -344,6 +345,8 @@ interface ShipmentBookingRow { tradeDirection: string; freightType: string; status: string; + /** Every required document approved, even before clearance is finalized. */ + allDocsApproved: boolean; /** Requested quantities from the originating shipment request. */ requested: Freight.RequestedShipmentLines | null; /** Contract this shipment booking was created under. */ @@ -518,13 +521,22 @@ function ShipmentBookingsTable({ header: () => Status, cell: ({ row }) => ( - - {prettyStatus(row.original.status)} - + {/* All docs approved but not yet finalized: the booking status is + still DOCUMENTS_UNDER_REVIEW — show the real review state. */} + {row.original.status === "DOCUMENTS_UNDER_REVIEW" && + row.original.allDocsApproved ? ( + + Documents approved + + ) : ( + + {prettyStatus(row.original.status)} + + )} {row.original.bookingCreated ? ( )} + {/* Clearance is done but the shipment is not paid yet: documents stay + open, so say so — otherwise the upload box below reads as leftover UI. */} + {canUpload && !isInitialUpload && status !== "DOCUMENTS_UNDER_REVIEW" ? ( + + + Need to send something else? You can still add documents to this + shipment until it is paid. + + + ) : null} + {canUpload ? (