import { BadRequestException } from '@nestjs/common'; import { ContractDocPhase } from '@edr/types'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; import type { Contract } from './entities/contract.entity'; import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import type { Booking } from '../bookings/entities/booking.entity'; function ms( code: string, status: 'PENDING' | 'COMPLETED' | 'SKIPPED', ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET', ): ClearanceMilestone { return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone; } function importThroughDeclaration(): ClearanceMilestone[] { return [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'), ms('DECLARED', 'PENDING', 'ET'), ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), ms('DO_COLLECTED', 'PENDING', 'DJ'), ]; } function makeService(milestones: ClearanceMilestone[]) { const contractsRepository = { currentCycle: jest.fn(), update: jest.fn(), setCycleStatus: jest.fn(), updateCycle: jest.fn(), }; const milestoneService = { listForContract: jest.fn().mockResolvedValue(milestones), listForBooking: jest.fn().mockResolvedValue(milestones), skipForContract: jest.fn(), completeForContract: jest.fn(), completeWithMetadataForContract: jest.fn(), }; const bookingsRepository = { update: jest.fn() }; const service = new ClearanceWorkflowService( contractsRepository as never, milestoneService as never, bookingsRepository as never, { clearanceReady: jest.fn() } as never, // notifier ); return { service, milestoneService, contractsRepository, bookingsRepository }; } const importContract = { id: 'c-import', tradeDirection: 'IMPORT', customsClearingEnabled: true, contractKind: 'ONE_TIME', } as Contract; const exportContract = { id: 'c-export', tradeDirection: 'EXPORT', customsClearingEnabled: true, contractKind: 'ONE_TIME', } as Contract; describe('ClearanceWorkflowService', () => { describe('boundaryMilestone', () => { it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => { const { service } = makeService([]); expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED'); expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED'); }); }); describe('assertPriorComplete', () => { it('rejects when a prior milestone is still pending', async () => { const milestones = importThroughDeclaration().map((m) => m.milestoneCode === 'DOCUMENTS_APPROVED' ? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET') : m, ); const { service } = makeService(milestones); await expect( service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'), ).rejects.toBeInstanceOf(BadRequestException); }); it('allows proceeding when prior milestones are completed or skipped', async () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), ms('DO_COLLECTED', 'PENDING', 'DJ'), ]; const { service } = makeService(milestones); await expect( service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'), ).resolves.toBeUndefined(); }); }); describe('isBoundaryComplete', () => { it('returns true only when boundary milestone is completed', async () => { const done = [ ...importThroughDeclaration().slice(0, -1), ms('DO_COLLECTED', 'COMPLETED', 'DJ'), ]; const { service: doneSvc } = makeService(done); await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true); const pending = importThroughDeclaration(); const { service: pendingSvc } = makeService(pending); await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false); }); }); describe('onDutySkipped', () => { it('skips duty milestones on the contract', async () => { const { service, milestoneService } = makeService([]); await service.onDutySkipped('c-import'); expect(milestoneService.skipForContract).toHaveBeenCalledWith( 'c-import', 'DUTY_TAXES_ADVISED', ); expect(milestoneService.skipForContract).toHaveBeenCalledWith( 'c-import', 'DUTY_TAX_PAID', ); }); }); describe('computeNextAction — import happy path', () => { it('prompts customer to upload docs first', () => { const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]); const next = service.computeNextAction(importContract, null, [ ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'), ]); expect(next?.actor).toBe('CUSTOMER'); expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED'); }); it('prompts ET review after customer docs', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), ]; const { service } = makeService(milestones); const next = service.computeNextAction(importContract, null, milestones); expect(next?.actor).toBe('GL_ET'); expect(next?.action).toMatch(/Review/i); }); it('prompts duty toggle when declaration done and duty unset', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), ]; const cycle = { dutyRequired: null } as ContractClearanceCycle; const { service } = makeService(milestones); const next = service.computeNextAction(importContract, cycle, milestones); expect(next?.actor).toBe('GL_ET'); expect(next?.action).toMatch(/duty/i); }); it('prompts customer duty slip when duty required and advised', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'), ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), ]; const cycle = { dutyRequired: true } as ContractClearanceCycle; const { service } = makeService(milestones); const next = service.computeNextAction(importContract, cycle, milestones); expect(next?.actor).toBe('CUSTOMER'); expect(next?.milestoneCode).toBe('DUTY_TAX_PAID'); }); it('skips duty path when duty not required', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), ]; const cycle = { dutyRequired: false } as ContractClearanceCycle; const { service } = makeService(milestones); const next = service.computeNextAction(importContract, cycle, milestones); expect(next?.actor).toBe('GL_ET'); expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); }); it('prompts ET to finalize pre-clearance after transit permit', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), ms('DO_COLLECTED', 'PENDING', 'DJ'), ]; const cycle = { dutyRequired: false } as ContractClearanceCycle; const { service } = makeService(milestones); const next = service.computeNextAction(importContract, cycle, milestones); expect(next?.actor).toBe('GL_ET'); expect(next?.action).toMatch(/finalize pre-clearance/i); }); it('prompts DJ for DO then ET booking when pre-booking complete', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), ms('DO_COLLECTED', 'PENDING', 'DJ'), ]; const cycle = { dutyRequired: false, preClearanceFinalizedAt: new Date(), } as ContractClearanceCycle; const { service } = makeService(milestones); const djNext = service.computeNextAction(importContract, cycle, milestones); expect(djNext?.actor).toBe('GL_DJ'); const booked = milestones.map((m) => m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m, ); const etNext = service.computeNextAction(importContract, cycle, booked); expect(etNext?.actor).toBe('GL_ET'); expect(etNext?.action).toMatch(/booking/i); }); }); describe('computeNextAction — export RO hold', () => { it('surfaces DJ action when RO is on hold', () => { const milestones = [ ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'), ]; const cycle = { roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).', } as ContractClearanceCycle; const { service } = makeService(milestones); const next = service.computeNextAction(exportContract, cycle, milestones); expect(next?.actor).toBe('GL_DJ'); expect(next?.blockedReason).toMatch(/minimum lead time/i); }); }); describe('inferPhase', () => { it('places import contract in customer duty phase when duty outstanding', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), ]; const cycle = { dutyRequired: true } as ContractClearanceCycle; const { service } = makeService(milestones); const phase = service.inferPhase(importContract, cycle, milestones); expect(phase).toBe(ContractDocPhase.CustomerDuty); }); }); describe('queue helpers', () => { it('returns first pending ET-owned milestone code', () => { const { service } = makeService([ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), ms('DO_COLLECTED', 'PENDING', 'DJ'), ]); expect(service.etPendingMilestoneCodes([])).toBeNull(); expect( service.etPendingMilestoneCodes([ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), ]), ).toBe('DOCUMENTS_APPROVED'); }); it('returns first pending DJ-owned milestone code', () => { const { service } = makeService([]); expect( service.djPendingMilestoneCodes([ ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), ms('DO_COLLECTED', 'PENDING', 'DJ'), ]), ).toBe('DO_COLLECTED'); }); }); describe('computeNextActionForBooking', () => { it('prompts customer to proceed after import boundary on booking', () => { const booking = { tradeDirection: 'IMPORT', dutyRequired: false, preClearanceFinalizedAt: new Date(), } as Booking; const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), ms('DECLARED', 'COMPLETED', 'ET'), ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), ms('DO_COLLECTED', 'COMPLETED', 'DJ'), ]; const { service } = makeService(milestones); const next = service.computeNextActionForBooking(booking, milestones); expect(next?.actor).toBe('CUSTOMER'); expect(next?.action).toMatch(/operation/i); }); }); });