diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 477724839..10b6afd37 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -176,6 +176,18 @@ export class ContractNotifierService { this.inApp(c, 'Contract suspension lifted', msg); } + /** + * Backoffice cancelled the contract. Terminal — the customer is told they may + * submit a new contract with the same details if they still need the service. + */ + cancelledByStaff(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` + + `If you still need this service you can submit a new contract request with the same details.`; + void this.notifyContact(c, msg, 'CANCELLED'); + this.inApp(c, 'Contract cancelled', msg); + } + /** Customer cancelled their own contract — staff-side record. */ cancelledByCustomer(c: Contract, reason: string): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts index 96a8a26ac..c535c909e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => { ]); }); }); + +describe('contract base freight ignores shipping-line rates', () => { + it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => { + // Both LIVE on the contract's own lane: the line rate sorted first and won, + // so the contract quoted 32 USD/wagon instead of the standard 1690. + const breakdown = await service([ + rate({ + containerTypeId: CT20, + rateValue: 32, + rateUnit: 'PER_WAGON', + shippingLineCompanyId: 'line-1', + }), + rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }), + ]); + }); + + it('blocks when the only rate on the lane belongs to a shipping line', async () => { + await expect( + service([ + rate({ + containerTypeId: CT20, + rateValue: 32, + shippingLineCompanyId: 'line-1', + }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 0af09a5f8..04be6460f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -85,7 +85,15 @@ export class ContractPricingService { * commodity rate) — NO totals or quantities (doc §9.1). */ async buildBreakdown(contract: Contract): Promise { - const liveRates = await this.ratesService.findLiveRates(); + // Contracts belong to a customer company — there is no shipping-line + // contract (no shipping_line_company_id on the entity), so a contract may + // only ever price off the standard rates. Without this filter a line's + // negotiated rate on the same lane matched first and the contract froze it + // for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and + // 23 USD/container 40ft instead of the standard 1690 / 1676. + const liveRates = (await this.ratesService.findLiveRates()).filter( + (r) => !r.shippingLineCompanyId, + ); const currency = contract.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts new file mode 100644 index 000000000..06a406bf1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts @@ -0,0 +1,113 @@ +import { ContractTransitionService } from './contract-transition.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Staff cancel is terminal, so the rules that matter are: it needs its own + * permission (suspend must NOT imply it), it refuses to strand live shipments, + * it works on a suspended contract, and it cannot be applied twice. + */ +describe('ContractTransitionService — staff cancel', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + status: 'CONTRACT_ACTIVE', + freightType: 'CONTAINER', + ...over, + }) as Contract; + + let current: Contract; + let repo: { + update: jest.Mock; + createReviewNote: jest.Mock; + countActiveBookings: jest.Mock; + }; + let notifier: { cancelledByStaff: jest.Mock }; + let service: ContractTransitionService; + + const staff = { + permissions: [{ key: 'edr_freight_app:contracts:cancel' }], + }; + + beforeEach(() => { + current = contract(); + repo = { + update: jest.fn().mockImplementation((_id: string, patch: object) => { + current = { ...current, ...patch } as Contract; + return Promise.resolve(current); + }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + countActiveBookings: jest.fn().mockResolvedValue(0), + }; + notifier = { cancelledByStaff: jest.fn() }; + service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsRepository: repo, + contractsService: { findById: () => Promise.resolve(current) }, + notifier, + }); + }); + + it('cancels, records the reason as a staff note, and notifies the customer', async () => { + await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'CANCELLED', + statusBeforeSuspension: null, + }); + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + 'Duplicate request', + 'CANCELLATION', + 'staff-1', + 'STAFF', + ); + expect(notifier.cancelledByStaff).toHaveBeenCalled(); + }); + + it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => { + current = contract({ + status: 'SUSPENDED', + statusBeforeSuspension: 'CONTRACT_ACTIVE', + } as Partial); + + await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'CANCELLED', + statusBeforeSuspension: null, + }); + }); + + it('refuses while a shipment is still running', async () => { + repo.countActiveBookings.mockResolvedValue(2); + + await expect( + service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never), + ).rejects.toThrow('2 active shipments'); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('refuses to cancel an already-terminal contract', async () => { + current = contract({ status: 'CANCELLED' }); + + await expect( + service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never), + ).rejects.toThrow(/already cancelled/i); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('rejects a user holding only the suspend key — cancel is a separate permission', async () => { + const suspender = { + permissions: [{ key: 'edr_freight_app:contracts:suspend' }], + }; + + await expect( + service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never), + ).rejects.toThrow(); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 936176f86..ac400d9d3 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -1452,6 +1452,54 @@ export class ContractTransitionService { return updated; } + /** + * Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one + * with the same parameters can be submitted afterwards (references are minted + * per contract, so nothing about the old row blocks the new one). + * + * Cancellable from ANY non-terminal status, including SUSPENDED: a frozen + * contract is exactly the one staff most often need to kill outright. + */ + async cancelByStaff( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel); + if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) { + throw new ConflictException( + `Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`, + ); + } + + // Same guard as the customer path: live shipments must be settled first, + // otherwise cancelling the contract orphans cargo already in motion. + const active = await this.contractsRepository.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + `This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` + + 'Cancel or complete them before cancelling the contract.', + ); + } + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CANCELLATION', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: 'CANCELLED', + statusBeforeSuspension: null, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.cancelledByStaff(updated, reason); + return updated; + } + async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 1dc083934..c38576fff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -72,6 +72,7 @@ import { RequestChangesDto, ResumeContractDto, SuspendContractDto, + CancelContractByStaffDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; @@ -552,6 +553,25 @@ export class ContractsController { ); } + @Post(':id/staff/cancel') + @BookingStaff(FREIGHT_PERMS.contracts.cancel) + @ApiOperation({ + summary: + 'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)', + }) + cancelByStaff( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelContractByStaffDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.cancelByStaff( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 6aa36b13d..63057978c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -51,6 +51,14 @@ export class CancelContractDto { reason?: string; } +/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */ +export class CancelContractByStaffDto { + @ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' }) + @IsString() + @MinLength(1) + reason!: string; +} + export class SuspendContractDto { @ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' }) @IsString() diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 4dad70ee1..2a8ac578e 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:contracts:suspend", "Suspend / resume a signed contract", ), + // Terminal kill switch. Unlike suspend this cannot be undone — the customer + // re-submits a fresh contract with the same parameters instead. + perm( + "a3000001-0001-4000-8000-00000000001c", + "edr_freight_app:contracts:cancel", + "Cancel a contract (terminal)", + ), ]; // Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and @@ -2066,6 +2073,7 @@ export const FREIGHT_PERMS = { clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", suspend: "edr_freight_app:contracts:suspend", + cancel: "edr_freight_app:contracts:cancel", editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", @@ -2876,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.generateContract, ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), FREIGHT_PERMS.contracts.suspend, + // Terminal kill switch, granted alongside suspend on the same desk that + // already rejects contracts and cancels bookings. + FREIGHT_PERMS.contracts.cancel, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, // Marketing follows up with the customer when a reviewer sends profile diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index d2a245f24..be70ffe4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { + Ban, Check, Eye, // FilePen, // ponytail: back with the "Edit contract articles" button @@ -81,6 +82,8 @@ export function ContractActionsToolbar({ const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]); // One key both ways — whoever can freeze a contract can unfreeze it. const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend); + // Cancel is its own key — it is terminal, so it is NOT implied by suspend. + const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel); const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); @@ -93,6 +96,67 @@ export function ContractActionsToolbar({ const [suspendReason, setSuspendReason] = useState(""); const [resumeOpen, setResumeOpen] = useState(false); const [resumeNote, setResumeNote] = useState(""); + const [cancelOpen, setCancelOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + + // Shared by the suspended branch and the normal toolbar — both can cancel. + const cancelModal = ( + setCancelOpen(false)} + title="Cancel this contract?" + centered + > + + + Contract {contract.reference} will be cancelled permanently. + This cannot be undone — there is no way to reactivate it. A new + contract with the same details can be submitted afterwards. The + customer is notified. + +