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

@@ -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();
});
});
});