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/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index deda35e1c..019d99920 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], 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/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 78c07bd22..f457e782a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService { } /** A clearance document was queried and needs the customer to re-upload. */ + /** GL asked the customer for additional clearance document(s). */ + additionalDocsRequested(b: Booking, note: string): void { + const msg = + `Additional document(s) requested on booking ${b.reference}: ` + + `${note} Please upload them from the portal.`; + void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED'); + this.inApp(b, 'Additional documents requested', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + documentQueried(b: Booking, fileKey: string, note: string): void { const msg = `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + 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 805b2216f..6d4d9073b 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 @@ -643,6 +643,12 @@ export class BookingTransitionService { }>; allApproved: boolean; documentsOpen: boolean; + docRequests: Array<{ + id: string; + note: string; + byName: string | null; + at: string; + }>; phase?: string | null; milestones?: unknown[]; nextAction?: unknown; @@ -674,9 +680,14 @@ export class BookingTransitionService { bookingId, "CHANGES_REQUESTED", ); + const docRequestNotes = await this.bookingsRepository.findReviewNotes( + bookingId, + "ADDITIONAL_DOC_REQUEST", + ); const reviewerNames = await this.bookingsRepository.resolveStaffNames([ ...reviews.map((r) => r.reviewedByStaffId), ...queryNotes.map((n) => n.authorId), + ...docRequestNotes.map((n) => n.authorId), ]); const documents: Awaited< @@ -763,9 +774,51 @@ export class BookingTransitionService { documents, allApproved, documentsOpen: clearanceDocumentsOpen(booking), + docRequests: docRequestNotes.map((n) => ({ + id: n.id, + note: n.note, + byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null, + at: n.createdAt.toISOString(), + })), }; } + /** + * GL asks the customer for additional clearance document(s). Stored as a + * review-note thread shown on both the GL clearance page and the customer's + * portal; the customer answers with an ad-hoc upload. Allowed for as long as + * documents are open (until the shipment is paid). + */ + async requestAdditionalDocuments( + bookingId: string, + note: string, + staffId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (!clearanceDocumentsOpen(booking)) { + throw new ConflictException( + `Clearance documents are closed for this booking (status "${booking.status}").`, + ); + } + if (!note?.trim()) { + throw new BadRequestException("Describe the document(s) you need."); + } + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + "ADDITIONAL_DOC_REQUEST", + staffId, + ); + await this.clearanceEvents.record({ + bookingId, + action: "ADDITIONAL_DOCS_REQUESTED", + label: "Requested additional document(s) from the customer", + actorId: staffId, + metadata: { note: note.trim() }, + }); + this.notifier.additionalDocsRequested(booking, note.trim()); + } + /** * True when every REQUIRED field of the booking's customer-input clearance set * has an APPROVED review row. The 100% gate before clearance can be finalized. 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 b1be94016..05d312384 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1083,6 +1083,25 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/clearance/doc-requests") + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + "GL asks the customer for additional clearance document(s) — shown on the portal with author and time", + }) + async requestAdditionalDocuments( + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string, + @CurrentUser() user: AuthUserPayload, + ) { + await this.transitionService.requestAdditionalDocuments( + id, + note, + resolveAuthUserId(user), + ); + return { success: true }; + } + @Get(":id/clearance/history") @BookingStaff([ FREIGHT_PERMS.contracts.clearanceEtActions, 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-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts index 969098196..8bac3ea2b 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [ * (price/files). One row per round — the draft/change-request loop can repeat. */ 'DRAFT_DECL_CHANGE_REQUEST', + /** + * GL asked the customer for additional clearance document(s). Shown as a + * thread on both the GL clearance page and the customer's portal — the + * customer answers by uploading an ad-hoc document. + */ + 'ADDITIONAL_DOC_REQUEST', ] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; 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 d9fe1825f..d393c011d 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 @@ -66,6 +66,12 @@ export interface BookingClearanceView { }>; allApproved: boolean; documentsOpen: boolean; + docRequests: Array<{ + id: string; + note: string; + byName: string | null; + at: string; + }>; phase?: string | null; milestones?: Array<{ id: string; @@ -206,9 +212,14 @@ export class BookingClearanceService { bookingId, 'CHANGES_REQUESTED', ); + const docRequestNotes = await this.bookingsRepository.findReviewNotes( + bookingId, + 'ADDITIONAL_DOC_REQUEST', + ); const reviewerNames = await this.bookingsRepository.resolveStaffNames([ ...reviews.map((r) => r.reviewedByStaffId), ...queryNotes.map((n) => n.authorId), + ...docRequestNotes.map((n) => n.authorId), ]); const documents: BookingClearanceView['documents'] = []; @@ -357,6 +368,12 @@ export class BookingClearanceService { documents, allApproved, documentsOpen: clearanceDocumentsOpen(booking), + docRequests: docRequestNotes.map((n) => ({ + id: n.id, + note: n.note, + byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null, + at: n.createdAt.toISOString(), + })), phase, milestones: milestones.map((m) => ({ id: m.id, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/AdditionalDocsRequestCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/AdditionalDocsRequestCard.tsx new file mode 100644 index 000000000..fd9abe744 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/AdditionalDocsRequestCard.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { MessageSquarePlus, Send } from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "./SectionCard"; +import { bookingsService } from "@/services/bookings.service"; +import { formatDateTime } from "@/lib/format"; +import { extractErrorMessage } from "@/utils/errorExtractor"; + +export interface AdditionalDocsRequestCardProps { + bookingId: string; + /** Past requests, newest first. */ + requests: Freight.ClearanceDocRequest[]; + /** False once the shipment is paid — documents (and requests) are closed. */ + canRequest: boolean; + onSent?: () => void; +} + +/** + * GL asks the customer for additional clearance document(s) in plain words. + * The message, its author and its time show on the customer's portal beside + * the upload box, so the customer knows exactly what to send and who asked. + */ +export function AdditionalDocsRequestCard({ + bookingId, + requests, + canRequest, + onSent, +}: AdditionalDocsRequestCardProps) { + const [note, setNote] = useState(""); + + const send = useMutation({ + mutationFn: () => bookingsService.requestAdditionalDocuments(bookingId, note), + onSuccess: () => { + toast.success("Request sent to the customer"); + setNote(""); + onSent?.(); + }, + onError: (e) => + toast.error(extractErrorMessage(e, "Could not send the request")), + }); + + return ( + + + {canRequest ? ( + +