Merge pull request #1461 from Tria-plc/freight_feature/usermanagement

feat(contracts): implement staff cancellation of contracts with reaso…
This commit is contained in:
marshal
2026-08-29 14:36:08 +03:00
committed by GitHub
13 changed files with 352 additions and 5 deletions

View File

@@ -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(

View File

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

View File

@@ -85,7 +85,15 @@ export class ContractPricingService {
* commodity rate) — NO totals or quantities (doc §9.1).
*/
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
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;

View File

@@ -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> = {}): 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<Contract>);
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();
});
});

View File

@@ -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<Contract> {
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<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -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' })

View File

@@ -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()

View File

@@ -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