mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
|
|
import { ContractClearanceService } from './contract-clearance.service';
|
|
import type { Contract } from './entities/contract.entity';
|
|
|
|
/**
|
|
* The duty advice → dispute → re-advice loop. 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('ContractClearanceService — duty dispute', () => {
|
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
|
({
|
|
id: 'ctr-1',
|
|
reference: 'CTR-2026-00042',
|
|
tradeDirection: 'IMPORT',
|
|
customsClearingEnabled: true,
|
|
contractKind: 'ONE_TIME',
|
|
...over,
|
|
}) as Contract;
|
|
|
|
const milestone = (code: string, status: string) =>
|
|
({ milestoneCode: code, status }) as never;
|
|
|
|
let repo: {
|
|
currentCycle: jest.Mock;
|
|
createReviewNote: jest.Mock;
|
|
updateCycle: jest.Mock;
|
|
findReviewNotes: jest.Mock;
|
|
};
|
|
let contractsService: { findById: jest.Mock };
|
|
let workflowService: { listMilestones: jest.Mock };
|
|
let milestoneService: { reopenForContract: jest.Mock };
|
|
let notifier: { dutyDisputed: jest.Mock };
|
|
let service: ContractClearanceService;
|
|
|
|
const build = (milestones: unknown[]) => {
|
|
workflowService.listMilestones.mockResolvedValue(milestones);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
repo = {
|
|
currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }),
|
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
|
updateCycle: jest.fn().mockResolvedValue(undefined),
|
|
findReviewNotes: jest.fn().mockResolvedValue([]),
|
|
};
|
|
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
|
workflowService = { listMilestones: jest.fn().mockResolvedValue([]) };
|
|
milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) };
|
|
notifier = { dutyDisputed: jest.fn() };
|
|
|
|
service = new ContractClearanceService(
|
|
repo as never,
|
|
contractsService as never,
|
|
{} as never, // bookingsService
|
|
{} as never, // filesService
|
|
{} as never, // fileUploadSettingsService
|
|
workflowService as never,
|
|
milestoneService as never,
|
|
{} as never, // dropdownSettingsService
|
|
{} as never, // glOperationsService
|
|
notifier as never,
|
|
{} as never, // transitAgentsService
|
|
);
|
|
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('ctr-1', ' Declared value is wrong ', 'user-1');
|
|
|
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
|
'ctr-1',
|
|
'Declared value is wrong',
|
|
'DUTY_DISPUTE',
|
|
'user-1',
|
|
'CUSTOMER',
|
|
);
|
|
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
|
expect(milestoneService.reopenForContract).toHaveBeenCalledWith(
|
|
'ctr-1',
|
|
'DUTY_TAXES_ADVISED',
|
|
);
|
|
expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', {
|
|
currentPhase: 'GL_ET_OUTPUT',
|
|
});
|
|
});
|
|
|
|
it('tells GL Ethiopia, not the customer', async () => {
|
|
await service.disputeDuty('ctr-1', 'Too high', 'user-1');
|
|
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'ctr-1' }),
|
|
'Too high',
|
|
);
|
|
});
|
|
|
|
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
|
await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(milestoneService.reopenForContract).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses when nothing has been advised yet', async () => {
|
|
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
|
await expect(service.disputeDuty('ctr-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('ctr-1', 'Too high')).rejects.toThrow(
|
|
/already been submitted/i,
|
|
);
|
|
});
|
|
|
|
it('refuses when duty was never required for this clearance', async () => {
|
|
repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false });
|
|
await expect(service.disputeDuty('ctr-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('ctr-1', milestones);
|
|
|
|
it('shows the objection while GL still owes a corrected advice', async () => {
|
|
repo.findReviewNotes.mockResolvedValue([
|
|
{ body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
|
{ body: '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([
|
|
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
|
]);
|
|
|
|
const dispute = await buildDispute([
|
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
|
]);
|
|
|
|
expect(dispute).toBeNull();
|
|
});
|
|
});
|
|
});
|