feat(contracts): lazily expire lapsed contracts and price fuel surcharge

This commit is contained in:
Marshal
2026-08-12 11:23:04 +00:00
parent a02d24806b
commit 3dbda745dd
5 changed files with 112 additions and 17 deletions

View File

@@ -157,13 +157,9 @@ export class ContractBookingService {
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
@@ -441,12 +437,9 @@ export class ContractBookingService {
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
@@ -545,9 +538,7 @@ export class ContractBookingService {
'Shipment-request initiation applies only to general customs contracts.',
);
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
await this.assertNotExpired(contract);
const route = await this.resolveRoute(contract, opts.contractRouteId);
@@ -681,9 +672,11 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
// already checked expiry at start). Finishing an in-flight booking must
// proceed even if the contract expires meanwhile — only starting a NEW
// booking is blocked (see assertNotExpired).
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a direct one-time booking passes at create.
@@ -1019,6 +1012,22 @@ export class ContractBookingService {
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
/**
* Blocks starting a NEW booking (create/initiate) once the contract has
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
* for the nightly sweep. Only for the "start something new" entry points —
* a booking already underway (completeUnderContract) must be allowed to
* finish even if the contract expires mid-flight.
*/
private async assertNotExpired(contract: Contract): Promise<void> {
if (!isEffectivelyExpired(contract)) return;
if (contract.status !== 'EXPIRED') {
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
if (flipped) contract.status = 'EXPIRED';
}
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
private async assertGate(
contract: Contract,
isGlActor: boolean,