From 8d7551bb8e1a84a3ee73e69554bb9f6229e37dcc Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 20 Aug 2026 08:43:08 +0000 Subject: [PATCH] fix(portal): show declaration, T1 and Djibouti clearance documents to the customer --- ...0000000000-MultipleMiscClearanceCharges.ts | 38 +++++++++ .../booking-clearance-charge.service.ts | 44 ++++------ .../booking-clearance-charge.entity.ts | 14 ++-- .../contracts/ClearanceChargesTab.tsx | 80 ++++++++++++------- 4 files changed, 112 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts diff --git a/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts b/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts new file mode 100644 index 000000000..5cdf6f489 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia + * may raise several MISCELLANEOUS charges, and either level may be created + * first. Port charges stay unique per booking (one port bill per shipment), + * enforced by a partial index instead of the old blanket (booking_id, type) + * uniqueness that also capped miscellaneous at one. + */ +export class MultipleMiscClearanceCharges3610000000000 + implements MigrationInterface +{ + name = 'MultipleMiscClearanceCharges3610000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port" + ON "freight"."booking_clearance_charge" ("booking_id") + WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking" + ON "freight"."booking_clearance_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // No-op on the uniqueness: restoring the blanket (booking_id, type) index + // would fail on any booking that has since raised a second miscellaneous + // charge, which is exactly what this migration set out to allow. + await queryRunner.query(` + DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index f41bff39d..3be119470 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -303,22 +303,8 @@ export class BookingClearanceChargeService { const booking = await this.bookingsService.findById(bookingId); this.assertClearanceFinalized(booking); - const port = await this.repo().findOne({ - where: { bookingId, type: 'PORT_CHARGES' }, - }); - if (port?.status !== 'PAID') { - throw new ConflictException( - 'Miscellaneous charges open after the port charge is paid.', - ); - } - const existing = await this.repo().findOne({ - where: { bookingId, type: 'MISCELLANEOUS' }, - }); - if (existing) { - throw new ConflictException( - 'This booking already has a miscellaneous charge — revise it instead.', - ); - } + // No ordering and no cap: a miscellaneous charge may be raised before, + // after or alongside the port charge, and a booking may carry several. if (!(input.amount > 0)) { throw new BadRequestException('Amount must be greater than zero.'); } @@ -326,21 +312,15 @@ export class BookingClearanceChargeService { throw new BadRequestException('Currency is required.'); } - const record = await this.filesService.upsertByCode( - { - resourceId: bookingId, - resource: 'bookings', - code: CHARGE_FILE_CODE.MISCELLANEOUS, - file, - }, - { userId: staffId }, - ); - await this.repo().save( + // Save the row first so its id can key the document. A booking may carry + // several miscellaneous charges, and `upsertByCode` retires whatever sits + // under the same code — a shared code would silently delete the previous + // charge's document. + const charge = await this.repo().save( this.repo().create({ bookingId, type: 'MISCELLANEOUS', status: 'BILLED', - fileRecordId: record.id, amount: input.amount.toFixed(2), currency: input.currency.trim().toUpperCase(), uploadedByStaffId: staffId, @@ -349,6 +329,16 @@ export class BookingClearanceChargeService { billedAt: new Date(), }), ); + const record = await this.filesService.upsertByCode( + { + resourceId: bookingId, + resource: 'bookings', + code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`, + file, + }, + { userId: staffId }, + ); + await this.repo().update(charge.id, { fileRecordId: record.id }); await this.clearanceEvents.record({ bookingId, action: 'CHARGE_MISC_CREATED', diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts index 8530ad11e..31ae26203 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-charge.entity.ts @@ -14,15 +14,15 @@ export const CLEARANCE_CHARGE_STATUSES = [ export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number]; /** - * Post-finalization clearance charge billed to the customer — at most one - * PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the - * port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency - * (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid` - * event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only - * after the port charge is paid. + * Clearance charge billed to the customer. One PORT_CHARGES row per booking + * (enforced by a partial unique index) and any number of MISCELLANEOUS rows. + * GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia + * sets amount + currency (BILLED) and issues the invoice (SENT); the billing + * `clearance_charge.invoice.paid` event marks it PAID. The two levels are + * independent — either may be raised first. */ @Entity({ schema: 'freight', name: 'booking_clearance_charge' }) -@Index(['bookingId', 'type'], { unique: true }) +@Index(['bookingId']) export class BookingClearanceCharge extends BaseEntity { @Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index 6f3fb530f..f8725b03a 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -67,6 +67,8 @@ export function ClearanceChargesTab({ onViewFile, }: ClearanceChargesTabProps) { const qc = useQueryClient(); + // Bumped after each create so the form remounts empty for the next charge. + const [miscCreated, setMiscCreated] = useState(0); const { data: charges, isLoading } = useQuery({ queryKey: ["clearance-charges", bookingId], queryFn: () => bookingsService.getClearanceCharges(bookingId), @@ -109,6 +111,8 @@ export function ClearanceChargesTab({ bookingsService.createMiscellaneousCharge(bookingId, p.file, p), onSuccess: (next) => { toast.success("Miscellaneous charge created"); + // Remount the form so the next charge starts from an empty one. + setMiscCreated((n) => n + 1); refresh(next); }, onError, @@ -124,7 +128,9 @@ export function ClearanceChargesTab({ } const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null; - const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null; + const miscCharges = (charges ?? []).filter( + (c) => c.type === "MISCELLANEOUS", + ); const busy = uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending; @@ -137,7 +143,7 @@ export function ClearanceChargesTab({ return ( - - misc && bill.mutate({ chargeId: misc.id, amount, currency }) - } - onSend={() => misc && send.mutate(misc.id)} - etCreate={ - roleMode === "ET" && !misc && port?.status === "PAID" ? ( - - createMisc.mutate({ file, amount, currency }) - } - /> - ) : null - } - /> + {/* Any number of miscellaneous charges, in any order relative to the + port charge — each is billed and paid on its own. */} + {miscCharges.map((c, i) => ( + 1 + ? `Miscellaneous charge ${i + 1}` + : "Miscellaneous charge" + } + charge={c} + roleMode={roleMode} + busy={busy} + emptyHint="" + onViewFile={onViewFile} + onBill={(amount, currency) => + bill.mutate({ chargeId: c.id, amount, currency }) + } + onSend={() => send.mutate(c.id)} + /> + ))} + + {roleMode === "ET" && ( + + + {miscCharges.length > 0 + ? "Add another miscellaneous charge" + : "Add a miscellaneous charge"} + + + Upload the supporting document and set the amount. You can raise as + many as the shipment needs, before or after the port charge. + + + createMisc.mutate({ file, amount, currency }) + } + /> + + )} {totals.size > 0 && (