let customers dispute booking duty/tax advice so GL Ethiopia can re-advise

This commit is contained in:
Marshal
2026-07-28 10:48:27 +00:00
parent e1c831211f
commit 891311d861
9 changed files with 426 additions and 6 deletions

View File

@@ -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 =

View File

@@ -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' })

View File

@@ -611,6 +611,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
);
}
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
async findReviewNotes(
bookingId: string,
type: ReviewNoteType,
): Promise<BookingReviewNote[]> {
return this.dataSource.getRepository(BookingReviewNote).find({
where: { bookingId, type },
order: { createdAt: 'DESC' },
});
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,

View File

@@ -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' })

View File

@@ -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<typeof buildWorkflowFiles>;
/** 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<BookingClearanceView['dutyDispute']> {
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<boolean> {
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<Booking> {
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<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {

View File

@@ -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> = {}): 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<unknown>;
}
).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();
});
});
});

View File

@@ -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 (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
@@ -109,11 +114,13 @@ export function BookingClearanceWorkflowBanner({
</Alert>
) : null}
{dutyPending && clearance.dutyAdvice ? (
{dutyDisputePending && clearance.dutyDispute ? (
<DutyDisputePendingCard dispute={clearance.dutyDispute} />
) : dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
onUploaded={() => void refetch()}
onChanged={() => void refetch()}
/>
) : null}
@@ -178,14 +185,17 @@ export function BookingClearanceWorkflowBanner({
function DutyAdvicePanel({
dutyAdvice,
bookingId,
onUploaded,
onChanged,
}: {
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
bookingId: string;
onUploaded: () => void;
onChanged: () => void;
}) {
const [file, setFile] = useState<File | null>(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
</Button>
{disputing ? (
<Stack gap={6}>
<Textarea
label="What's wrong with this amount?"
placeholder="Explain why you're disputing the advised duty/tax…"
minRows={2}
autosize
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group gap="xs">
<Button
variant="default"
size="xs"
onClick={() => {
setDisputing(false);
setNote("");
}}
>
Cancel
</Button>
<Button
color="red"
size="xs"
loading={submittingDispute}
disabled={!note.trim()}
onClick={async () => {
setSubmittingDispute(true);
try {
await bookingsService.disputeBookingClearanceDuty(
bookingId,
note.trim(),
);
toast.success("Sent to GL Ethiopia for review");
setDisputing(false);
setNote("");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to send");
} finally {
setSubmittingDispute(false);
}
}}
>
Submit request
</Button>
</Group>
</Stack>
) : (
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => setDisputing(true)}
>
Not right? Request a change
</Anchor>
)}
</Stack>
</Paper>
);
}
/** The customer's dispute is open — GL Ethiopia owes a corrected advice. */
function DutyDisputePendingCard({
dispute,
}: {
dispute: NonNullable<Freight.ClearanceView["dutyDispute"]>;
}) {
return (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Waiting on GL Ethiopia (round ${dispute.rounds})`
: "Waiting on GL Ethiopia"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Sent {new Date(dispute.raisedAt).toLocaleString()} you'll see the
corrected amount here once GL Ethiopia re-advises.
</Text>
</Stack>
</Alert>
);
}
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>

View File

@@ -398,6 +398,16 @@ export const bookingsService = {
return data.data ?? data;
},
disputeBookingClearanceDuty: async (
id: string,
note: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/duty/dispute`, {
note,
});
return data.data ?? data;
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data;

View File

@@ -800,6 +800,15 @@ export interface ClearanceView {
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;
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
/** Import post-allocation T1 transit document state (null until wagon allocation). */