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,

View File

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

View File

@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
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<boolean> {
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

View File

@@ -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) => {

View File

@@ -211,6 +211,19 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
</Text>
</Fragment>
))}
{w.trainNumber ? (
<Text
fz={13}
fw={700}
style={{
color: INK,
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
}}
>
Train {w.trainNumber}
</Text>
) : null}
</Group>
<Group gap={6} wrap="nowrap" mt={8}>
@@ -221,7 +234,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
</Group>
{w.departureDate ? (
<Text fz={12} style={{ color: MUTED }}>
Departs {fmtDay(w.departureDate)}
Departs {fmtDay(w.departureDate)}, {fmtTime(w.departureDate)}
</Text>
) : null}
</Box>