mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
let customers dispute booking duty/tax advice so GL Ethiopia can re-advise
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user