From 891311d8611bbe96f93e26473cea5b78ecafb1ff Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 28 Jul 2026 10:48:27 +0000 Subject: [PATCH] let customers dispute booking duty/tax advice so GL Ethiopia can re-advise --- .../booking-lifecycle-notifier.service.ts | 15 ++ .../modules/bookings/bookings.controller.ts | 18 ++ .../modules/bookings/bookings.repository.ts | 11 ++ .../entities/booking-review-note.entity.ts | 11 +- .../contracts/booking-clearance.service.ts | 86 ++++++++++ .../contracts/booking-duty-dispute.spec.ts | 162 ++++++++++++++++++ .../BookingClearanceWorkflowBanner.tsx | 110 +++++++++++- .../portal/src/services/bookings.service.ts | 10 ++ packages/types/src/freight/index.ts | 9 + 9 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts 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 132652909..5e8b6aa0e 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 @@ -392,6 +392,21 @@ export class BookingLifecycleNotifierService { ); } + /** + * The customer disputed the advised duty & tax. This goes to STAFF, not the + * customer: GL Ethiopia is the one who has to re-advise, and the clearance + * page is where they do it. + */ + dutyDisputed(b: Booking, note: string): void { + const msg = + `The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` + + `"${note}". Review and re-advise the amount on the clearance page.`; + this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { const label = 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 8fb15c74c..5b6bddbff 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -900,6 +900,24 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/duty/dispute') + @ApiOperation({ + summary: + 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', + }) + async disputeBookingDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.disputeDuty( + id, + note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 0d7708789..b9ced4f96 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -611,6 +611,17 @@ export class BookingsRepository extends BaseRepository { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + bookingId: string, + type: ReviewNoteType, + ): Promise { + return this.dataSource.getRepository(BookingReviewNote).find({ + where: { bookingId, type }, + order: { createdAt: 'DESC' }, + }); + } + async findLatestReviewNote( bookingId: string, type?: ReviewNoteType, 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 91171a793..75007326e 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 @@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Booking } from './booking.entity'; -export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const; +export const REVIEW_NOTE_TYPES = [ + 'CHANGES_REQUESTED', + 'REJECTION', + 'STAFF_NOTE', + /** + * The customer disputed the advised duty & tax and asked GL Ethiopia to + * correct it. One row per round — the advice/dispute loop can repeat. + */ + 'DUTY_DISPUTE', +] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; @Entity({ schema: 'freight', name: 'booking_review_note' }) 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 656ef20e4..bfb44852a 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 @@ -88,6 +88,15 @@ export interface BookingClearanceView { declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * The customer's open objection to the advised duty. Present only until GL + * re-advises; `rounds` counts how many times it has been sent back. + */ + dutyDispute?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; @@ -222,6 +231,7 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const dutyDispute = await this.buildDutyDispute(bookingId, milestones); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', @@ -291,6 +301,7 @@ export class BookingClearanceService { : null, }, dutyAdvice, + dutyDispute, workflowFiles, t1, train, @@ -348,6 +359,22 @@ export class BookingClearanceService { }; } + private async buildDutyDispute( + bookingId: string, + milestones: ClearanceMilestone[], + ): Promise { + const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); + if (!advised || advised.status === 'COMPLETED') return null; + const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE'); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.note, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; @@ -534,6 +561,65 @@ export class BookingClearanceService { return this.bookingsService.findById(bookingId); } + /** + * The customer disagrees with the advised duty & tax on this booking and asks + * GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so + * the Duty & tax step becomes actionable again on the GL clearance page, with + * the customer's message shown beside it. GL re-advises (same endpoint as the + * first time), which closes the dispute — the loop may run as many rounds as + * it takes. + */ + async disputeDuty( + bookingId: string, + note: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty applies only to import bookings.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what is wrong with the advised amount so GL can correct it.', + ); + } + if (!booking.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { + throw new BadRequestException( + 'There is no advised duty amount to dispute yet.', + ); + } + // Once the slip is in, the money is paid — a dispute then is a refund + // conversation, not a re-advice. + if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', + ); + } + + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + 'DUTY_DISPUTE', + userId, + ); + // Back to GL: reopening the milestone is what re-arms the Duty & tax step + // (the stepper picks its active step from milestone completion). + await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.dutyDisputed(updated, note.trim()); + return updated; + } + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts new file mode 100644 index 000000000..4a3904182 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts @@ -0,0 +1,162 @@ +import { BadRequestException } from '@nestjs/common'; + +import { BookingClearanceService } from './booking-clearance.service'; +import type { Booking } from '../bookings/entities/booking.entity'; + +/** + * The duty advice → dispute → re-advice loop, at the booking level. GL + * Ethiopia advises an amount; the customer either pays it or sends it back + * with a reason. Sending it back reopens the advice milestone — that is what + * puts the Duty & tax step back in GL's hands — and the round can repeat + * until the amount is agreed. + */ +describe('BookingClearanceService — duty dispute', () => { + const booking = (over: Partial = {}): Booking => + ({ + id: 'bk-1', + reference: 'BKG-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractId: 'ctr-1', + dutyRequired: true, + ...over, + }) as Booking; + + const milestone = (code: string, status: string) => + ({ milestoneCode: code, status }) as never; + + let repo: { + createReviewNote: jest.Mock; + findReviewNotes: jest.Mock; + update: jest.Mock; + }; + let bookingsService: { findById: jest.Mock }; + let workflowService: { listMilestonesForBooking: jest.Mock }; + let milestoneService: { reopenForBooking: jest.Mock }; + let notifier: { dutyDisputed: jest.Mock }; + let service: BookingClearanceService; + + const build = (milestones: unknown[]) => { + workflowService.listMilestonesForBooking.mockResolvedValue(milestones); + }; + + beforeEach(() => { + repo = { + createReviewNote: jest.fn().mockResolvedValue(undefined), + findReviewNotes: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + bookingsService = { findById: jest.fn().mockResolvedValue(booking()) }; + workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) }; + milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) }; + notifier = { dutyDisputed: jest.fn() }; + + service = new BookingClearanceService( + repo as never, + bookingsService as never, + {} as never, // filesService + {} as never, // fileUploadSettingsService + workflowService as never, + milestoneService as never, + {} as never, // dropdownSettingsService + {} as never, // glOperationsService + notifier as never, + ); + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'PENDING'), + ]); + }); + + it('records the objection and hands the step back to GL', async () => { + await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'bk-1', + 'Declared value is wrong', + 'DUTY_DISPUTE', + 'user-1', + ); + // Reopening the advice milestone is what re-arms the Duty & tax step. + expect(milestoneService.reopenForBooking).toHaveBeenCalledWith( + 'bk-1', + 'DUTY_TAXES_ADVISED', + ); + expect(repo.update).toHaveBeenCalledWith('bk-1', { + clearanceCurrentPhase: 'GL_ET_OUTPUT', + }); + }); + + it('tells GL Ethiopia, not the customer', async () => { + await service.disputeDuty('bk-1', 'Too high', 'user-1'); + expect(notifier.dutyDisputed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'bk-1' }), + 'Too high', + ); + }); + + it('requires a reason — GL cannot correct an unexplained objection', async () => { + await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(milestoneService.reopenForBooking).not.toHaveBeenCalled(); + }); + + it('refuses when nothing has been advised yet', async () => { + build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]); + await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( + /no advised duty amount/i, + ); + }); + + it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => { + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'COMPLETED'), + ]); + await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( + /already been submitted/i, + ); + }); + + it('refuses when duty was never required for this clearance', async () => { + bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false })); + await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( + /not required/i, + ); + }); + + describe('the view', () => { + const buildDispute = (milestones: unknown[]) => + ( + service as unknown as { + buildDutyDispute: (id: string, m: unknown[]) => Promise; + } + ).buildDutyDispute('bk-1', milestones); + + it('shows the objection while GL still owes a corrected advice', async () => { + repo.findReviewNotes.mockResolvedValue([ + { note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') }, + { note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'PENDING'), + ]); + + expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 }); + }); + + it('clears itself once GL re-advises', async () => { + repo.findReviewNotes.mockResolvedValue([ + { note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + ]); + + expect(dispute).toBeNull(); + }); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx index fe2a96e33..1589d250b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx @@ -10,12 +10,14 @@ import { Paper, Stack, Text, + Textarea, } from "@mantine/core"; import { AlertTriangle, Download, Eye, FileBadge, + MessageSquareWarning, Receipt, Upload, } from "lucide-react"; @@ -84,6 +86,9 @@ export function BookingClearanceWorkflowBanner({ clearance.dutyRequired && clearance.dutyAdvice && !dutyPaid; + // A dispute clears the advice while it's open — show the "waiting on GL" + // state instead of the pay panel until GL re-advises. + const dutyDisputePending = Boolean(clearance.dutyDispute); return ( @@ -109,11 +114,13 @@ export function BookingClearanceWorkflowBanner({ ) : null} - {dutyPending && clearance.dutyAdvice ? ( + {dutyDisputePending && clearance.dutyDispute ? ( + + ) : dutyPending && clearance.dutyAdvice ? ( void refetch()} + onChanged={() => void refetch()} /> ) : null} @@ -178,14 +185,17 @@ export function BookingClearanceWorkflowBanner({ function DutyAdvicePanel({ dutyAdvice, bookingId, - onUploaded, + onChanged, }: { dutyAdvice: NonNullable; bookingId: string; - onUploaded: () => void; + onChanged: () => void; }) { const [file, setFile] = useState(null); const [loading, setLoading] = useState(false); + const [disputing, setDisputing] = useState(false); + const [note, setNote] = useState(""); + const [submittingDispute, setSubmittingDispute] = useState(false); const noticeFile = dutyAdvice.noticeFile; return ( @@ -229,7 +239,7 @@ function DutyAdvicePanel({ try { await bookingsService.uploadBookingClearanceDutySlip(bookingId, file); toast.success("Payment slip uploaded"); - onUploaded(); + onChanged(); } catch (e) { toast.error(e instanceof Error ? e.message : "Upload failed"); } finally { @@ -239,11 +249,101 @@ function DutyAdvicePanel({ > Submit payment slip + + {disputing ? ( + +