From 3dbda745dd67d2df905eea701836ad49752fc3dd Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 11:23:04 +0000 Subject: [PATCH] feat(contracts): lazily expire lapsed contracts and price fuel surcharge --- .../contracts/contract-booking.service.ts | 39 +++++++++------- .../contracts/contract-pricing.service.ts | 44 ++++++++++++++++++- .../modules/contracts/contracts.repository.ts | 21 +++++++++ .../modules/contracts/contracts.service.ts | 10 +++++ .../ContractBookingWindowsSection.tsx | 15 ++++++- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index d5989f2ac..3d362d53f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 { + 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, 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 924c1a72d..3f49b127f 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 @@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity'; export interface ContractUnitRateLineItem { code: string; label: string; - unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; + unit: + | 'per_container' + | 'per_wagon' + | 'per_ton' + | 'per_item' + | 'per_km' + | 'per_liter' + | 'flat'; unitPrice: number; containerSize?: string | null; conditionalOn?: string | null; @@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] { return 'per_wagon'; case 'PER_CONTAINER': return 'per_container'; + case 'PER_LITER': + return 'per_liter'; default: return 'flat'; } @@ -276,6 +285,39 @@ export class ContractPricingService { } } + // Fuel surcharge — shown when the contract's commodity incurs fuel + // (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the + // frozen/live rate (per wagon × wagons, or per liter × base liters, once); + // this line freezes the agreed unit price. + { + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + if (scope?.cargoType?.hasFuel && route) { + const fuel = liveRates.find( + (r) => + r.trigger === 'FUEL' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId && + r.cargoTypeId === scope.cargoTypeId, + ); + if (fuel && Number(fuel.rateValue) > 0) { + const base = Number(fuel.baseLiters ?? 0); + lineItems.push({ + code: 'FUEL_SURCHARGE', + label: + fuel.rateUnit === 'PER_LITER' + ? `Fuel surcharge (${scope.cargoType.cargoTypeName}, ${base} liters)` + : `Fuel surcharge (${scope.cargoType.cargoTypeName})`, + unit: toContractUnit(fuel.rateUnit), + unitPrice: convert(Number(fuel.rateValue)), + cargoTypeCode: scope.cargoType.code ?? null, + conditionalOn: 'has_fuel', + }); + } + } + } + // Empty-container return service — container contracts only, toggled on the // contract like hazard/reefer. Billed at booking per WITH_RETURN container. if ( diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 2ab7e3d7e..3e76581a8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository { return result.affected ?? 0; } + /** + * Same-row version of expireLapsedContracts, for lazy flips on read/booking + * paths — flips this one contract to EXPIRED if it's lapsed and not already + * terminal. No-op (returns false) if the contract isn't actually lapsed, so + * callers can call this unconditionally without a pre-check. + */ + async expireIfLapsed(id: string): Promise { + const result = await this.repository + .createQueryBuilder() + .update(Contract) + .set({ status: 'EXPIRED' }) + .where('id = :id', { id }) + .andWhere('deleted_at IS NULL') + .andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES }) + .andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', { + now: new Date(), + }) + .execute(); + return (result.affected ?? 0) > 0; + } + /** * Live contracts whose validity ends between `days` and `days + 1` days from * now — the slice the daily expiry-reminder cron warns about. The window is diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 6e170f126..c6379b4a6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -811,6 +811,16 @@ export class ContractsService { throw new NotFoundException(`Contract ${id} not found`); } + // Lazy expiry flip: the nightly cron only sweeps once a day, so a + // contract can be past contract_valid_until for hours before it shows + // EXPIRED. Flip it here so the detail page never shows a stale status. + if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') { + const flipped = await this.contractsRepository.expireIfLapsed(id); + if (flipped) { + contract.status = 'EXPIRED'; + } + } + if (contract.files && contract.files.length > 0) { contract.files = await Promise.all( contract.files.map(async (file: FileRecord) => { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index c4fa72977..73295cda0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -211,6 +211,19 @@ function WindowCard({ w }: { w: MyBookingWindow }) { ))} + {w.trainNumber ? ( + + Train {w.trainNumber} + + ) : null} @@ -221,7 +234,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) { {w.departureDate ? ( - Departs {fmtDay(w.departureDate)} + Departs {fmtDay(w.departureDate)}, {fmtTime(w.departureDate)} ) : null}