revert back the clerance payment

This commit is contained in:
Marshal
2026-07-23 13:54:09 +00:00
parent 13609f8d59
commit 15a6bab5d0
54 changed files with 1222 additions and 712 deletions

View File

@@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
REEFER: 'Reefer (refrigerated) surcharge',
WITH_RETURN: 'Empty-container return service',
SHIPPING_LINE: 'Shipping line handling',
CONSOLIDATION: 'Container consolidation (extra document)',
CONSOLIDATION: 'Penalty (container consolidation)',
LASHING: 'Cargo lashing and securing',
CANCELLATION: 'Booking cancellation fee',
DEMURRAGE: 'Demurrage / wagon detention',

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The customs clearance service fee is no longer prepaid via its own
* `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the
* booking invoice, together with the freight (see BookingPricingService).
*
* - Contracts/bookings parked at the payment gate move straight to the
* document step (the gate no longer exists — nothing could ever pay them).
* - Open (unpaid) clearance invoices are expired; PAID ones stay as history.
* NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but
* has not booked yet will be billed the fee again on its booking invoice —
* accepted for dev data; reverses the old AddClearanceFeePayment migration.
* - clearance_fee_paid_at columns are dropped from contracts and bookings.
*/
export class DropClearanceFeePrepay2860000000000 implements MigrationInterface {
name = 'DropClearanceFeePrepay2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.contracts
SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now()
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
`);
await queryRunner.query(`
UPDATE freight.contracts
SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now()
WHERE clearance_status = 'AWAITING_PAYMENT';
`);
await queryRunner.query(`
UPDATE freight.bookings
SET status = 'AWAITING_DOCUMENTS', updated_at = now()
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
`);
await queryRunner.query(`
UPDATE freight.invoices
SET status = 'EXPIRED', updated_at = now()
WHERE source = 'clearance'
AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE');
`);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Moved rows and expired invoices stay — only the columns come back.
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customs clearance fees are now sold per cargo kind: container fees name a
* container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type
* (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be
* mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the
* base-freight and return-surcharge reshapes, kept readable for snapshot
* history. Per-kind replacements must be re-entered; a customs contract or
* booking without a matching fee hard-blocks. Contracts that already froze a
* FLAT snapshot keep billing it (legacy honoured at booking pricing).
*/
export class CustomsClearancePerKind2870000000000 implements MigrationInterface {
name = 'CustomsClearancePerKind2870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND rate_type = 'CUSTOMS_CLEARANCE'
AND rate_unit = 'FLAT';
`);
}
public async down(): Promise<void> {
// Retired rates stay retired — re-enter per-kind rates instead.
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Lashing is now sold per cargo kind, like the customs clearance fee:
* container rates name a container type (PER_CONTAINER / PER_WAGON), bulk
* rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape
* cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept
* readable for snapshot history. Per-kind replacements must be re-entered;
* an unconfigured lashing rate simply bills nothing (lenient, like
* hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates
* share the LASHING rate_type and must survive.
*/
export class LashingPerKind2880000000000 implements MigrationInterface {
name = 'LashingPerKind2880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'LASHING'
AND rate_unit = 'FLAT';
`);
}
public async down(): Promise<void> {
// Retired rates stay retired — re-enter per-kind rates instead.
}
}

View File

@@ -16,7 +16,6 @@ import {
InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
@@ -121,8 +120,7 @@ export class BookingInvoiceService {
}
/**
* Expire the booking's currently-open invoices (freight PREPAID and the
* per-shipment clearance fee) when the booking is
* Expire the booking's currently-open freight (PREPAID) invoice when the booking is
* cancelled or rejected — the counterpart to the pay-window-expiry path
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
* booking from leaving a payable invoice open. No-op when the booking has no
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
bookingId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
// id under its own source/type — retire it alongside the freight invoice, or
// a cancelled shipment keeps a payable clearance invoice open.
await this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
bookingId,
CLEARANCE_BOOKING_INVOICE_TYPE,
manager,
);
return this.billing.expirePayable(
Freight.InvoiceSource.Booking,
bookingId,

View File

@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
ratesService as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
);
});
@@ -240,3 +241,216 @@ describe('BookingPricingService — domestic corridor', () => {
expect(result.blocked[0]).toContain('rate is configured');
});
});
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
const DJ = 'yard-dj';
const containerFee20: Rate = {
id: 'rate-cc-20',
rateType: 'CUSTOMS_CLEARANCE',
trigger: 'CUSTOMS_CLEARANCE',
currency: 'USD',
rateValue: 100,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: 'ct-20',
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: DIRE,
} as Rate;
const bulkFeePerTon: Rate = {
...containerFee20,
id: 'rate-cc-bulk',
rateValue: 5,
rateUnit: 'PER_TON',
containerTypeId: null,
} as Rate;
const emptyEval = {
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
};
const makeService = (opts: {
snapshots?: unknown[];
liveRates?: Rate[];
wagonCapacity?: number;
}) =>
new BookingPricingService(
{
calculateWagonCount: jest.fn().mockResolvedValue(0),
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
} as never,
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
{
findById: jest.fn(async (id: string) => ({
id,
sizeFt: id === 'ct-40' ? 40 : 20,
isReefer: false,
code: id === 'ct-40' ? 'C40' : 'C20',
})),
} as never,
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes:
opts.wagonCapacity !== undefined
? [{ capacityTons: opts.wagonCapacity }]
: [],
}),
} as never,
);
const containerBooking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-cc',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
customsClearingEnabled: true,
originYardId: DJ,
destinationYardId: DIRE,
bookingContainers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
],
...overrides,
}) as unknown as Booking;
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-cc-bulk',
freightType: 'BULK',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
customsClearingEnabled: true,
cargoTypeId: 'cargo-1',
cargoTotalWeightVgm: 120,
originYardId: DJ,
destinationYardId: DIRE,
bookingContainers: [],
...overrides,
}) as unknown as Booking;
it('bills a container booking per box at its own container type fee', async () => {
const service = makeService({ liveRates: [containerFee20] });
const result = await service.computePriceForBooking(containerBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
expect(line).toBeDefined();
expect(line!.unit).toBe('PER_CONTAINER');
expect(line!.quantity).toBe(4);
expect(line!.amount).toBe(400);
});
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
const service = makeService({
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
});
const result = await service.computePriceForBooking(containerBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2);
expect(line!.amount).toBe(200);
});
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(containerBooking());
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
});
it('bills a bulk booking per ton at the route bulk fee', async () => {
const service = makeService({ liveRates: [bulkFeePerTon] });
const result = await service.computePriceForBooking(bulkBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unit).toBe('PER_TON');
expect(line!.quantity).toBe(120);
expect(line!.amount).toBe(600);
});
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
const service = makeService({
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
wagonCapacity: 60,
});
const result = await service.computePriceForBooking(bulkBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
expect(line!.amount).toBe(100);
});
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
const service = makeService({
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
});
const result = await service.computePriceForBooking(bulkBooking());
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
});
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
const service = makeService({
liveRates: [containerFee20],
snapshots: [
{
rateCode: 'CUSTOMS_CLEARANCE_20FT',
unitPrice: 80,
currency: 'USD',
unitOfMeasure: 'per_container',
isClearance: true,
},
],
});
const result = await service.computePriceForBooking(
containerBooking({ contractId: 'c-1' }),
);
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
});
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
const service = makeService({
liveRates: [],
snapshots: [
{
rateCode: 'CUSTOMS_CLEARANCE',
unitPrice: 500,
currency: 'USD',
unitOfMeasure: 'flat',
isClearance: true,
},
],
});
const result = await service.computePriceForBooking(
containerBooking({ contractId: 'c-legacy' }),
);
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unit).toBe('FLAT');
expect(line!.amount).toBe(500);
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
});
it('adds no fee line when customs clearing is disabled', async () => {
const service = makeService({ liveRates: [containerFee20] });
const result = await service.computePriceForBooking(
containerBooking({ customsClearingEnabled: false }),
);
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
@@ -10,7 +11,10 @@ import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -76,6 +80,7 @@ export class BookingPricingService {
private readonly ratesService: RatesService,
private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService,
private readonly cargoTypesService: CargoTypesService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -223,6 +228,23 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
}
// Customs clearance service fee (Path B) — billed HERE, on the booking
// invoice with the freight; no separate prepaid clearance invoice. Sold per
// cargo kind: container bookings bill each container type's own fee (per
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
// wagon). Frozen contract snapshots win over live rates; a customs booking
// with nothing configured hard-blocks — clearance never ships for free.
const clearanceBlocked: string[] = [];
if (booking.customsClearingEnabled) {
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
for (const line of clearance.lineItems) {
lineItems.push(line);
total += line.amount;
}
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
clearanceBlocked.push(...clearance.blocked);
}
// Overweight detail for the customer: map the engine's per-line results back
// to the booking's container lines (same order) for code + weights. maxAllowed
// is derived from the line total minus the excess the engine computed.
@@ -260,7 +282,7 @@ export class BookingPricingService {
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
overweightLines,
};
}
@@ -321,6 +343,8 @@ export class BookingPricingService {
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
reeferQuantity: Number(bc.reeferQuantity ?? 0),
returnQuantity: Number(bc.returnQuantity ?? 0),
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
},
perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty,
@@ -338,6 +362,13 @@ export class BookingPricingService {
),
)
: 0;
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
// Deliberately NOT totalWagons — that would shift wagon-count priority
// scoring for bulk bookings.
const bulkWagons =
booking.freightType === 'BULK'
? ((await this.bulkWagonCount(booking)) ?? 0)
: 0;
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
// a container type leaves a wagon partially filled. Aggregate by type first —
@@ -386,6 +417,7 @@ export class BookingPricingService {
booking.freightType === 'BULK'
? Number(booking.cargoTotalWeightVgm ?? 0)
: 0,
bulkWagons,
containers,
};
}
@@ -894,6 +926,184 @@ export class BookingPricingService {
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
}
/**
* Customs clearance service fee lines for a customs booking (Path B), billed
* with the freight. Container bookings bill each container line at its own
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
* win over live rates; contracts frozen before the per-kind model carry one
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
*/
private async customsClearanceLines(
booking: Booking,
frozenRates: Map<string, ContractRateSnapshot> | null,
liveRates: Rate[],
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
const lineItems: PriceLineItemDto[] = [];
const usedRates: Rate[] = [];
const blocked: string[] = [];
const currency = booking.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' &&
r.tradeDirection === booking.tradeDirection &&
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
const missingRateMessage = (scope: string): string =>
`No customs clearance service fee is configured for ${scope} on this ` +
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
if (booking.freightType === 'CONTAINER') {
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service',
amount,
unitAmount: amount,
unit: 'FLAT',
quantity: 1,
currency,
});
}
return { lineItems, usedRates, blocked };
}
for (const bc of booking.bookingContainers ?? []) {
if (!bc.containerTypeId) continue;
const qty = Number(bc.quantity || 0);
if (!(qty > 0)) continue;
let sizeFt = 0;
try {
sizeFt =
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
} catch {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
continue;
}
const unit = frozen
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
: live!.rateUnit;
const unitAmount = frozen
? Number(frozen.unitPrice)
: convert(Number(live!.rateValue));
const billedQty =
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (!(amount > 0)) continue;
lineItems.push({
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
amount,
unitAmount,
unit,
quantity: unit === 'FLAT' ? 1 : billedQty,
currency,
});
if (live && !frozen) usedRates.push(live);
}
return { lineItems, usedRates, blocked };
}
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const live = onLeg.find((r) => !r.containerTypeId);
if (!frozen && !live) {
blocked.push(missingRateMessage('bulk cargo'));
return { lineItems, usedRates, blocked };
}
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
let billedQty = 1;
if (unit === 'PER_TON') {
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
} else if (unit === 'PER_WAGON') {
const wagons = await this.bulkWagonCount(booking);
if (wagons == null) {
blocked.push(
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
'no wagon type with a capacity configured — the wagon count cannot ' +
'be derived. Ask EDR to configure the cargo types wagon types.',
);
return { lineItems, usedRates, blocked };
}
billedQty = wagons;
}
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
if (amount > 0) {
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
description: 'Customs clearance service (bulk)',
amount,
unitAmount,
unit,
quantity: unit === 'FLAT' ? 1 : billedQty,
currency,
});
if (live && !frozen) usedRates.push(live);
}
return { lineItems, usedRates, blocked };
}
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
private rateUnitFromSnapshot(unitOfMeasure: string): string {
switch (unitOfMeasure) {
case 'per_wagon':
return 'PER_WAGON';
case 'per_ton':
return 'PER_TON';
case 'per_container':
return 'PER_CONTAINER';
default:
return 'FLAT';
}
}
/**
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
* largest-capacity wagon type its cargo type allows. Null when the chain is
* unconfigured (no cargo type, no wagon types, no capacity).
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
* may stock a smaller type and use more wagons.
*/
private async bulkWagonCount(booking: Booking): Promise<number | null> {
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
if (!(tons > 0) || !booking.cargoTypeId) return null;
try {
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
const capacity = Math.max(
0,
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
);
if (!(capacity > 0)) return null;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
return null;
}
}
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]

View File

@@ -605,11 +605,6 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
throw new ConflictException(
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
);
}
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",

View File

@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Post counter-sign document-clearance gate (GL workflow).
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
clearanceCurrentPhase?: string | null;
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
clearanceFeePaidAt?: Date | null;
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null;

View File

@@ -1,239 +0,0 @@
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { ContractPricingBreakdown } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity';
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
/**
* The prepaid customs clearance service fee (Path B) — the GL service charge,
* separate from both freight (booking invoice) and duty/tax (paid offline).
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
* document step opens and before GL touches the file:
* - ONE_TIME: once per contract, at staff counter-sign
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
* - GENERAL: once per shipment request, on the initiated booking instance
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
* customers pay what their contract shows, not the live rate of the day.
*/
@Injectable()
export class ClearanceFeeService {
private readonly logger = new Logger(ClearanceFeeService.name);
constructor(
private readonly billing: BillingService,
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly notifier: ContractNotifierService,
) {}
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
private async feeAmountOrNull(
contract: Contract,
): Promise<{ amount: number; currency: string } | null> {
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
const snapshot = snapshots.find(
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
);
if (snapshot && Number(snapshot.unitPrice) > 0) {
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
}
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
if (line && Number(line.unitPrice) > 0) {
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
}
return null;
}
private async feeAmount(
contract: Contract,
): Promise<{ amount: number; currency: string }> {
const fee = await this.feeAmountOrNull(contract);
if (!fee) {
throw new UnprocessableEntityException(
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
);
}
return fee;
}
/**
* Whether the payment gate applies. Skipped for government/unlinked
* contracts (no company to bill — invoices require one, same rule the
* booking invoice applies) and for legacy customs contracts frozen before
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
* the pre-fee flow instead of dead-ending.
*/
async gateApplies(contract: Contract): Promise<boolean> {
// Customs disabled → the prepay gate genuinely does not apply.
if (!contract.customsClearingEnabled) return false;
// No company to bill (government / unlinked) → the gate cannot raise an
// invoice, so it stays out of the flow (same rule the booking invoice uses).
if (!contract.companyId) return false;
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
// silently waive the gate — that ships clearance for free. Hard-fail exactly
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
// missing fee blocks counter-sign / shipment instead of bypassing payment.
if ((await this.feeAmountOrNull(contract)) === null) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
);
}
return true;
}
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
async issueForContract(contract: Contract): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Clearance,
contract.id,
CLEARANCE_CONTRACT_INVOICE_TYPE,
);
if (existing) return existing;
const { amount, currency } = await this.feeAmount(contract);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Clearance,
sourceId: contract.id,
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
companyId: contract.companyId!,
companyProfileId: contract.companyProfileId!,
currency,
lines: [
{
chargeType: 'CUSTOMS_CLEARANCE',
description: `Customs clearance service fee — contract ${contract.reference}`,
quantity: 1,
unitRate: amount,
amount,
currency,
},
],
status: Freight.InvoiceStatus.Pending,
});
this.notifier.clearanceFeeDue(contract, amount, currency);
return invoice;
}
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Clearance,
booking.id,
CLEARANCE_BOOKING_INVOICE_TYPE,
);
if (existing) return existing;
const { amount, currency } = await this.feeAmount(contract);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Clearance,
sourceId: booking.id,
type: CLEARANCE_BOOKING_INVOICE_TYPE,
companyId: booking.companyId ?? contract.companyId!,
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
currency,
lines: [
{
chargeType: 'CUSTOMS_CLEARANCE',
description: `Customs clearance service fee — shipment ${booking.reference}`,
quantity: 1,
unitRate: amount,
amount,
currency,
},
],
status: Freight.InvoiceStatus.Pending,
});
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
return invoice;
}
/**
* Retire (idempotently) the unpaid contract-level fee invoice when the
* contract reaches a terminal state — a dead contract must not leave a
* payable clearance invoice open for the customer to settle. No-op when the
* fee was already paid or never invoiced (mirrors the booking cancel path,
* {@link BillingService.expirePayable}).
*/
async expireForContract(contractId: string): Promise<Invoice | null> {
return this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
contractId,
CLEARANCE_CONTRACT_INVOICE_TYPE,
);
}
/**
* Settlement branch point for `clearance`-source invoices: unlock the
* document-upload step the fee was gating. Idempotent — a replayed event on
* an already-advanced contract/booking is a no-op.
*/
@OnEvent('clearance.invoice.paid')
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
this.logger.log(
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
);
switch (payload.type) {
case CLEARANCE_CONTRACT_INVOICE_TYPE:
await this.advanceContract(payload.sourceId);
break;
case CLEARANCE_BOOKING_INVOICE_TYPE:
await this.advanceBooking(payload.sourceId);
break;
default:
this.logger.warn(
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
private async advanceContract(contractId: string): Promise<void> {
const contract = await this.contractsRepository.findById(contractId);
if (!contract) {
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
return;
}
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
await this.contractsRepository.update(contractId, {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
clearanceFeePaidAt: new Date(),
} as never);
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
if (updated) this.notifier.clearanceFeePaid(updated);
}
private async advanceBooking(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
return;
}
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
await this.bookingsRepository.update(bookingId, {
status: 'AWAITING_DOCUMENTS',
clearanceFeePaidAt: new Date(),
} as never);
if (booking.contractId) {
const contract = await this.contractsRepository.findByIdWithRelations(
booking.contractId,
);
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
}
}
}

View File

@@ -26,7 +26,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService

View File

@@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{} as never, // clearanceFeeService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService

View File

@@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import {
@@ -97,7 +96,6 @@ export class ContractBookingService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly clearanceFeeService: ClearanceFeeService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
@@ -537,11 +535,8 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, opts.contractRouteId);
// Prepay gate: each shipment request owes its own flat clearance service
// fee before the document step opens (the paid event advances the booking
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
const feeGate = await this.clearanceFeeService.gateApplies(contract);
// No prepay gate: the clearance service fee is billed on the booking
// invoice at completion, so the document step opens immediately.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
@@ -551,7 +546,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -590,10 +585,6 @@ export class ContractBookingService {
contract.tradeDirection,
);
if (feeGate) {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
const created =
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
this.bookingNotifier.createdToStaff(created);

View File

@@ -499,11 +499,6 @@ export class ContractClearanceService {
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
throw new ConflictException(
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
);
}
if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW'

View File

@@ -178,26 +178,6 @@ export class ContractNotifierService {
});
}
/** Clearance service fee invoiced — customer must pay before document upload. */
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
`Please pay from the portal to unlock the clearance document upload.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
this.inApp(c, 'Clearance fee due', msg);
}
/** Clearance service fee settled — document upload is now open. */
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`Your customs clearance service fee for ${scope} has been received. ` +
`You can now upload the clearance documents from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
this.inApp(c, 'Clearance fee paid', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -10,7 +10,7 @@ import { Contract } from './entities/contract.entity';
export interface ContractUnitRateLineItem {
code: string;
label: string;
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
unitPrice: number;
containerSize?: string | null;
conditionalOn?: string | null;
@@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
return 'per_ton';
case 'PER_KM':
return 'per_km';
case 'PER_CONTAINER':
case 'PER_WAGON':
return 'per_wagon';
case 'PER_CONTAINER':
return 'per_container';
default:
return 'flat';
@@ -240,18 +241,19 @@ export class ContractPricingService {
}
}
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
// contract and billed via its own clearance invoice: after counter-sign for
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
// A customs contract may not proceed without a configured live rate.
// Customs clearance service fee (Path B) — billed on the booking invoice
// together with the freight. Sold per direction + route + cargo kind:
// container contracts freeze one fee line per contract size (each size's
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// The fee is sold per direction + route — strict, no route-less fallback.
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const clearance = route
? liveRates.find(
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' &&
@@ -259,22 +261,55 @@ export class ContractPricingService {
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: undefined;
if (!clearance || Number(clearance.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
);
: [];
if (contract.freightType === 'CONTAINER') {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedIds = new Set(
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
);
const rate = onLeg.find(
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
isClearance: true,
});
}
} else {
// Bulk fee = the route's type-less customs rate (per ton / per wagon).
const rate = onLeg.find((r) => !r.containerTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this origin → destination.',
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: 'Customs clearance service (bulk)',
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
isClearance: true,
});
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label:
contract.contractKind === 'GENERAL'
? 'Customs clearance service fee (per shipment request, prepaid)'
: 'Customs clearance service fee (prepaid)',
unit: toContractUnit(clearance.rateUnit),
unitPrice: convert(Number(clearance.rateValue)),
isClearance: true,
});
}
return {

View File

@@ -36,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
@@ -162,7 +161,6 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -591,10 +589,6 @@ export class ContractTransitionService {
actorId,
'STAFF',
);
// Stop the open-invoice leak: a rejected contract must not leave a payable
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
await this.clearanceFeeService.expireForContract(contractId);
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
@@ -654,10 +648,6 @@ export class ContractTransitionService {
'STAFF',
);
// Stop the open-invoice leak: a rejected contract must not leave a payable
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
await this.clearanceFeeService.expireForContract(contractId);
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
@@ -669,9 +659,9 @@ export class ContractTransitionService {
/**
* Internal send-back branch of rejectStep: return the contract to an earlier,
* already-approved stage of the chain instead of rejecting it outright.
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
* is still alive) and no customer-facing REJECTION note — the trail is a
* staff note plus a backoffice inbox ping.
* Deliberately NOT the terminal path: the contract is still alive and there
* is no customer-facing REJECTION note — the trail is a staff note plus a
* backoffice inbox ping.
*/
private async sendBackToStep(
contract: Contract,
@@ -1169,17 +1159,11 @@ export class ContractTransitionService {
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
// Path B prepay gate: the customs clearance service fee is invoiced here
// and must settle before the document step opens (the paid event advances
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
if (await this.clearanceFeeService.gateApplies(contract)) {
await this.clearanceFeeService.issueForContract(contract);
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
updates.clearanceStatus = 'AWAITING_PAYMENT';
} else {
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
}
// No prepay gate: the customs clearance service fee (Path B) is billed on
// the booking invoice together with the freight, so the document step
// opens immediately.
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract

View File

@@ -361,8 +361,15 @@ export class ContractsController {
);
}
// Readable by anyone who may view the contract: the draft carries
// `editableByMe`, and the approval chain's approvers (identified by position
// type, not by staff_accept) must be able to fetch it to learn it is their
// turn. Gating this on staff_accept hid the edit dialog from every approver.
@Get(':id/document/draft')
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@BookingStaff([
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@ApiOperation({
summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
@@ -385,8 +392,15 @@ export class ContractsController {
return this.documentHistory.list(id);
}
// Coarse gate only. WHO may actually edit is turn-based, not a static
// permission, so `updateContractDocument` -> `assertDocumentEditable` is the
// real boundary: it admits only the approver whose step is currently pending
// (edit rights hand off down the chain on each approval).
@Put(':id/document/articles')
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@BookingStaff([
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@ApiOperation({
summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',

View File

@@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
@@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService,
ContractsRepository,
ContractPricingService,
ClearanceFeeService,
ContractNotifierService,
ContractTransitionService,
ContractDocumentHistoryService,

View File

@@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity {
conditionalOn?: string | null;
/**
* Customs clearance service fee line — billed up front via a clearance
* invoice, excluded from shipment booking totals.
* Customs clearance service fee line — billed on the booking invoice
* together with the freight (no separate prepaid clearance invoice).
*/
@Column({ name: 'is_clearance', type: 'boolean', default: false })
isClearance!: boolean;

View File

@@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
@@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE',
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
@@ -217,10 +215,6 @@ export class Contract extends BaseEntity {
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
clearanceCycleNumber!: number;
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
clearanceFeePaidAt?: Date | null;
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record<string, unknown> | null;

View File

@@ -10,6 +10,7 @@ import {
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['USD'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
export class CreateRateDto {
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
@@ -47,6 +48,15 @@ export class CreateRateDto {
@IsIn([...INTERCITY_KINDS])
intercityKind?: string;
@ApiPropertyOptional({
enum: CARGO_KINDS,
description:
'Whether a customs clearance / lashing rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or LASHING. Not stored — container fees carry a containerTypeId, bulk fees none.',
})
@IsOptional()
@IsIn([...CARGO_KINDS])
cargoKind?: string;
@ApiPropertyOptional({
description:
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',

View File

@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / LASHING only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
}): RateUnit[] {
const { appliesTo, trigger } = input;
@@ -29,16 +31,18 @@ export function allowedRateUnits(input: {
case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON'];
case 'WITH_RETURN':
// Container-only empty-return service — bills per returned container.
return ['PER_CONTAINER', 'FLAT'];
// Container-only empty-return service — per returned container, per
// wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE':
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
return ['FLAT'];
case 'LASHING':
// Flat cargo-securing fee, billed once per booking.
return ['FLAT'];
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'
? ['PER_TON', 'PER_WAGON']
: ['PER_CONTAINER', 'PER_WAGON'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':
@@ -74,6 +78,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
unit: RateUnit;
}): boolean {
return allowedRateUnits(input).includes(input.unit);

View File

@@ -249,6 +249,49 @@ describe('RuleEngineService — empty-container return per route + container typ
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
});
it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
// Same service, but the return rate is sold per wagon: 4× 20ft return =
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{
findLiveRates: jest
.fn()
.mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const result = await service.evaluate(
returnInput({
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
returnQuantity: 4,
wagonsPerUnit: 0.5,
},
],
}),
);
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(ret).toHaveLength(1);
expect(ret[0].triggerValue).toBe(2);
expect(ret[0].calculatedAmount).toBe(40);
expect(ret[0].billingUnit).toBe('PER_WAGON');
});
it('legacy booking-level flag bills every container at its type rate', async () => {
const result = await service.evaluate(
returnInput({
@@ -264,3 +307,128 @@ describe('RuleEngineService — empty-container return per route + container typ
expect(ret[0].calculatedAmount).toBe(80);
});
});
describe('RuleEngineService — lashing per cargo kind', () => {
const lashing20: Rate = {
id: 'rate-lash-20',
rateType: 'LASHING',
trigger: 'LASHING',
rateValue: 30,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
tradeDirection: null,
originYardId: null,
destinationYardId: null,
} as Rate;
const lashingBulk: Rate = {
...lashing20,
id: 'rate-lash-bulk',
rateValue: 2,
rateUnit: 'PER_TON',
containerTypeId: null,
} as Rate;
const buildService = (rates: Rate[]): RuleEngineService =>
new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const containerInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
hasLashing: true,
totalWagons: 2,
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
wagonsPerUnit: 0.5,
},
],
...overrides,
});
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
hasLashing: true,
totalWagons: 0,
bulkTons: 100,
bulkWagons: 3,
containers: [],
...overrides,
});
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
it('bills each container line at its own container type rate', async () => {
const result = await buildService([lashing20]).evaluate(containerInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].triggerValue).toBe(4);
expect(mods[0].calculatedAmount).toBe(120);
expect(mods[0].billingUnit).toBe('PER_CONTAINER');
});
it('PER_WAGON container lashing bills the wagons the line occupies', async () => {
const result = await buildService([
{ ...lashing20, rateUnit: 'PER_WAGON' } as Rate,
]).evaluate(containerInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(2);
expect(mods[0].calculatedAmount).toBe(60);
expect(mods[0].billingUnit).toBe('PER_WAGON');
});
it('an unmatched container type simply bills nothing (lenient)', async () => {
const result = await buildService([
{ ...lashing20, containerTypeId: 'ct-40' } as Rate,
]).evaluate(containerInput());
expect(lashingMods(result)).toHaveLength(0);
});
it('bulk lashing bills per ton on the tonnage', async () => {
const result = await buildService([lashingBulk]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(100);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('PER_TON');
});
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
const result = await buildService([
{ ...lashingBulk, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(3);
expect(mods[0].calculatedAmount).toBe(75);
});
it('no lashing charge when the cargo does not need lashing', async () => {
const result = await buildService([lashing20]).evaluate(
containerInput({ hasLashing: false }),
);
expect(lashingMods(result)).toHaveLength(0);
});
});

View File

@@ -48,6 +48,12 @@ export interface BookingContainerEvalInput {
hazardousQuantity?: number;
reeferQuantity?: number;
returnQuantity?: number;
/**
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
* ride back on. Missing ⇒ one wagon per container.
*/
wagonsPerUnit?: number;
}
export interface BookingEvaluationInput {
@@ -86,6 +92,12 @@ export interface BookingEvaluationInput {
* container freight, which is scaled by container count instead.
*/
bulkTons?: number;
/**
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
* 0/undefined when unknown — those charges then bill nothing.
*/
bulkWagons?: number;
containers: BookingContainerEvalInput[];
}
@@ -312,6 +324,9 @@ export class RuleEngineService {
// Empty-container return is sold per route + container type — billed by
// the route-matched block below, never by this route-agnostic loop.
if (rate.trigger === 'WITH_RETURN') continue;
// Lashing is sold per cargo kind + container type — billed by the
// kind-aware block below, never by this generic loop.
if (rate.trigger === 'LASHING') continue;
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
@@ -419,6 +434,10 @@ export class RuleEngineService {
appliedModifiers.push(...withReturn.modifiers);
hardBlocked.push(...withReturn.blocked);
if (hasLashing) {
appliedModifiers.push(...this.lashingCharges(input, liveRates));
}
return {
priorityScore,
appliedModifiers,
@@ -538,12 +557,18 @@ export class RuleEngineService {
}
const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
// PER_WAGON bills the wagons the returned empties occupy (two 20ft share
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
const billed =
rate.rateUnit === 'PER_WAGON'
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
: qty;
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
if (!(amount > 0)) continue;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: qty,
triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
@@ -555,6 +580,70 @@ export class RuleEngineService {
return { modifiers, blocked: [...new Set(blocked)] };
}
/**
* Cargo securing / lashing — sold per cargo kind, like the customs clearance
* fee. Container bookings bill each container line at its own container
* type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the
* line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage,
* or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply
* bills nothing — same leniency as hazard/reefer.
*/
private lashingCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
const lashingRates = liveRates.filter(
(r) => r.trigger === 'LASHING' && r.currency === 'USD',
);
if (lashingRates.length === 0) return modifiers;
const push = (rate: Rate, billedQty: number): void => {
const rateValue = Number(rate.rateValue);
const amount =
rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
if (!(amount > 0)) return;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
};
if (input.containers.length > 0) {
for (const container of input.containers) {
const qty = Number(container.quantity || 0);
if (!(qty > 0)) continue;
const rate = lashingRates.find(
(r) => r.containerTypeId === container.containerTypeId,
);
if (!rate) continue;
const billedQty =
rate.rateUnit === 'PER_WAGON'
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
: qty;
push(rate, billedQty);
}
return modifiers;
}
// Bulk — the type-less rate covers the whole booking.
const rate = lashingRates.find((r) => !r.containerTypeId);
if (!rate) return modifiers;
const billedQty =
rate.rateUnit === 'PER_TON'
? Math.max(0, Number(input.bulkTons ?? 0))
: rate.rateUnit === 'PER_WAGON'
? Math.max(0, Number(input.bulkWagons ?? 0))
: 1;
push(rate, billedQty);
return modifiers;
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
expect(request.payload).toEqual({ rateValue: 200 });
});
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
const { service } = build({
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
});
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 100,
originYardId: 'yard-a',
destinationYardId: 'yard-c',
},
});
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(

View File

@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
'tradeDirection',
'containerTypeId',
'cargoTypeId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
'destinationYardId',
] as const;
/**

View File

@@ -69,18 +69,19 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'] | undefined,
cargoKind?: 'CONTAINER' | 'BULK' | null,
): Rate['rateUnit'] {
// Overweight is per-ton, full stop — the admin form hides the unit field
// for it and omits rateUnit from the payload entirely.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
const allowed = allowedRateUnits({ appliesTo, trigger });
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
if (!requestedUnit) {
throw new BadRequestException(
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
);
}
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
);
@@ -187,17 +188,42 @@ export class RatesService {
trigger: Rate['trigger'];
tradeDirection: string | null;
intercityKind: string | null;
cargoKind: string | null;
containerTypeId: string | null;
cargoTypeId: string | null;
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') {
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing';
// Customs is sold per direction; lashing applies both ways.
if (
trigger === 'CUSTOMS_CLEARANCE' &&
tradeDirection !== 'IMPORT' &&
tradeDirection !== 'EXPORT'
) {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
);
}
// Sold per cargo kind: a container fee names the container type it covers
// (20ft and 40ft price differently); a bulk fee carries no type at all —
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
`A ${label} rate must say whether it covers containers or bulk.`,
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
`A container ${label} rate must name the container type it covers.`,
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
`A bulk ${label} rate cannot be scoped to a container type.`,
);
}
return;
}
if (trigger === 'WITH_RETURN') {
@@ -279,11 +305,17 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance keeps a direction, and empty-container
// return keeps direction + container type — both are sold per lane.
// Exceptions: customs clearance and empty-container return keep direction +
// container type — both are sold per lane (and per container type).
const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId =
trigger === 'WITH_RETURN'
trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null)
: isSurcharge
? null
@@ -304,6 +336,7 @@ export class RatesService {
trigger,
tradeDirection,
intercityKind,
cargoKind,
containerTypeId,
cargoTypeId,
});
@@ -325,6 +358,7 @@ export class RatesService {
appliesTo,
trigger,
dto.rateUnit as Rate['rateUnit'] | undefined,
cargoKind,
);
await this.assertNoDuplicatePattern({
@@ -419,7 +453,19 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger;
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
// A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type).
const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
@@ -455,6 +501,7 @@ export class RatesService {
trigger,
tradeDirection: updates.tradeDirection,
intercityKind,
cargoKind,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
});
@@ -486,7 +533,7 @@ export class RatesService {
// Re-validate the unit against the (possibly changed) shape; overweight is
// forced to PER_TON.
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({

View File

@@ -416,9 +416,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
// Cargo-securing / lashing — flat fee, billed once per booking whose
// cargo type has hasLashing = true.
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" },
// Cargo-securing / lashing — fires when the cargo type has hasLashing.
// Bulk lashing seeds per ton; container lashing is per container type
// and is configured by the rates team (no catch-all container seed).
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "PER_TON" },
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },