Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts

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