diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index af9b9428c..48eadb6c7 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { 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', diff --git a/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts new file mode 100644 index 000000000..9fd854b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts @@ -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 { + 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 { + // 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts new file mode 100644 index 000000000..be01e7825 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts @@ -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 { + 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 { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts new file mode 100644 index 000000000..9d311c60f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts @@ -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 { + 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 { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3d8222d63..82bb1144e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -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 { - // 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, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index fecd9111a..14ea7334d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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 = {}) => + ({ + 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 = {}) => + ({ + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index e233a4fe6..401561ccb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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 { @@ -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 | 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 type’s 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 { + 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] diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 36705daa0..ec6e466b2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -605,11 +605,6 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { 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", diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 2aa3ee8c2..821b9c9f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts deleted file mode 100644 index 8de7aed87..000000000 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ /dev/null @@ -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 { - // 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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); - } - } -} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 7f0aaabea..563f056d2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 68c02fa7d..bb74f062c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -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 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 96357c8c4..8e102e5a1 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 @@ -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); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 029952f45..d76391f42 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -499,11 +499,6 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { 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' diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index ac4fe2a0f..fd81083fc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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. */ 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 d40d5dd6f..8a10862b2 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 @@ -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. + 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 { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index a8e2f5b62..54158c383 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 7e4c10d6f..ba80cc035 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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)', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 050417a45..bb12648a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts index 52fcd437f..244a2816d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 4838d3f52..b8635199d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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 | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 41971bbf1..65332ac42 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -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.', diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 78e2eb724..ee7bc44b1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 8a5ee8e31..472efbf55 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -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 => ({ + 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 => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + hasLashing: true, + totalWagons: 0, + bulkTons: 100, + bulkWagons: 3, + containers: [], + ...overrides, + }); + + const lashingMods = (result: Awaited>) => + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 1c053a820..a1621c4f6 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index 6c3adce66..be7c8984c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 357c67f95..36c55dad3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -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; /** diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 203cabaa2..f1417020c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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({ diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 072c559c6..d1ff3d31c 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -416,9 +416,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { 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" }, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 8c3e6f071..3d1226dd6 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -65,8 +65,12 @@ export function computeGlShipmentTotal( (i) => i.containerSize === line.containerSize && i.unit === "per_container" && - !i.conditionalOn, - ) ?? rateFor((i) => i.containerSize === line.containerSize); + !i.conditionalOn && + !i.isClearance, + ) ?? + rateFor( + (i) => i.containerSize === line.containerSize && !i.isClearance, + ); if (rate) { lines.push({ label: rate.label, @@ -123,7 +127,9 @@ export function computeGlShipmentTotal( } else { const qty = q.bulkQuantity; const rate = - rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0]; + rateFor( + (i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance, + ) ?? items[0]; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -159,6 +165,36 @@ export function computeGlShipmentTotal( } } + // Customs clearance service fee — billed on the booking invoice with the + // freight. Container fees estimate per size (per box, or per wagon: two 20ft + // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on + // the wagon capacity the train stocks — shown at real pricing, not estimated. + for (const cl of items.filter((i) => i.isClearance)) { + let qty = 0; + if (q.isContainer) { + const boxes = q.containers + .filter((c) => c.containerSize === cl.containerSize) + .reduce((s, c) => s + Number(c.quantity || 0), 0); + qty = + cl.unit === "per_wagon" + ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) + : boxes; + } else if (cl.unit === "per_ton") { + qty = q.bulkQuantity; + } else if (cl.unit === "flat") { + qty = 1; + } + if (qty > 0) { + lines.push({ + label: cl.label, + unitPrice: cl.unitPrice, + unit: cl.unit, + quantity: qty, + amount: cl.unitPrice * qty, + }); + } + } + const total = lines.reduce((s, l) => s + l.amount, 0); return { currency, lines, total }; } @@ -167,6 +203,7 @@ export function computeGlShipmentTotal( export function formatRateUnit(unit: Freight.ContractRateUnit | string): string { const map: Record = { per_container: "container", + per_wagon: "wagon", per_ton: "ton", per_item: "item", per_km: "km", diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 1c9d94825..36c925d98 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -209,6 +209,12 @@ const RuleEngineFormDialog = ({ next.containerTypeId = ""; next.cargoTypeId = ""; } + // Cargo kind (customs / lashing) decides both the container-type scope + // and the legal units (container → per box/wagon, bulk → per ton/wagon). + if (name === "cargoKind") { + next.containerTypeId = ""; + next.rateUnit = ""; + } return next; }); }; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index e5961240f..07dbae7ce 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [ key: "clearance", label: "Clearance", statuses: [ - "AWAITING_CLEARANCE_PAYMENT", "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY", diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts index cf1f04c90..2277d702b 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts @@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record = { label: "Active", color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", }, - AWAITING_CLEARANCE_PAYMENT: { - label: "Clearance Fee Due", - color: "bg-orange-50 text-orange-700 border-orange-200", - }, AWAITING_CLEARANCE_DOCUMENTS: { label: "Awaiting Documents", color: "bg-amber-50 text-amber-700 border-amber-200", @@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record = { SIGNED_CUSTOMER: "cyan", FULLY_EXECUTED: "indigo", CONTRACT_ACTIVE: "edr-green", - AWAITING_CLEARANCE_PAYMENT: "orange", AWAITING_CLEARANCE_DOCUMENTS: "yellow", CLEARANCE_UNDER_REVIEW: "yellow", CLEARANCE_READY_FOR_BOOKING: "edr-green", @@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record = { color: "text-[color:var(--freight-brand)]", stage: 3, }, - AWAITING_CLEARANCE_PAYMENT: { - title: "Clearance Fee Due", - description: - "Customer must pay the prepaid clearance service fee before uploading documents.", - color: "text-orange-600", - stage: 3, - }, AWAITING_CLEARANCE_DOCUMENTS: { title: "Awaiting Documents", description: "Customer is uploading pre-booking clearance documents.", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx index 6156762a0..a61376158 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -25,6 +25,8 @@ const FIELD_LABELS: Record = { tradeDirection: "Direction", containerTypeId: "Container type", cargoTypeId: "Cargo type", + originYardId: "Origin yard", + destinationYardId: "Destination yard", }; const fmtDateTime = (iso: string) => @@ -36,12 +38,20 @@ const fmtDateTime = (iso: string) => hour12: false, }); -const fmtValue = (field: string, value: unknown): string => { +const fmtValue = ( + field: string, + value: unknown, + labels?: Record, +): string => { if (value === null || value === undefined || value === "") return "—"; if (field === "rateValue") { const num = Number(value); return Number.isNaN(num) ? String(value) : num.toLocaleString(); } + // Yard ids are unreadable — an approver decides on the route, not a UUID. + if (field === "originYardId" || field === "destinationYardId") { + return labels?.[String(value)] ?? String(value); + } return String(value).replace(/_/g, " "); }; @@ -77,6 +87,8 @@ interface RateApprovalsSectionProps { canDecide: boolean; approve: Decide; reject: Decide; + /** yardId → label, so a re-routed rate reads as yards, not UUIDs. */ + yardLabels?: Record; } /** @@ -89,6 +101,7 @@ const RateApprovalsSection = ({ canDecide, approve, reject, + yardLabels, }: RateApprovalsSectionProps) => { const [openId, setOpenId] = useState(null); const [notes, setNotes] = useState>({}); @@ -212,11 +225,11 @@ const RateApprovalsSection = ({ {FIELD_LABELS[field] ?? field} - {fmtValue(field, r.previousValues[field])} + {fmtValue(field, r.previousValues[field], yardLabels)} - {fmtValue(field, r.payload[field])} + {fmtValue(field, r.payload[field], yardLabels)} ))} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 582dcef88..536f04ba7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -269,6 +269,10 @@ const RuleEngineResourcePage = () => { ); const { data: yardOptions, isLoading: yardOptionsLoading } = useYardOptions(usesYardField); + const yardLabelById = useMemo( + () => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])), + [yardOptions], + ); const usesApprovalRoleField = Boolean( config?.formFields.some( (f) => f.name === "requiredRole" || f.name === "blocksRole", @@ -661,6 +665,7 @@ const RuleEngineResourcePage = () => { canDecide={canApproveRates} approve={rateChangeWorkflow.approve} reject={rateChangeWorkflow.reject} + yardLabels={yardLabelById} /> ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 02ddb5b45..7521f7ac9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -171,12 +171,11 @@ const RATE_TRIGGERS = [ value: "WITH_RETURN", }, { label: "Shipping line mapped", value: "SHIPPING_LINE" }, - { label: "Consolidation", value: "CONSOLIDATION" }, - { label: "Lashing (flat, per booking)", value: "LASHING" }, + { label: "Penalty", value: "CONSOLIDATION" }, + { label: "Lashing (per container type / bulk)", value: "LASHING" }, { label: "Cancellation", value: "CANCELLATION" }, - { label: "Demurrage", value: "DEMURRAGE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, - { label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" }, + { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, ]; /** @@ -211,7 +210,11 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value * bill per container, bulk per ton, overweight always per excess ton, etc. Kept * in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts. */ -const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { +const allowedRateUnits = ( + appliesTo: string, + trigger: string, + cargoKind = "", +): string[] => { if (appliesTo === "OTHER") { switch (trigger) { case "OVERWEIGHT": @@ -221,16 +224,16 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { case "DEMURRAGE": return ["PER_CONTAINER", "PER_TON"]; case "WITH_RETURN": - // Container-only service — bills per returned container. - return ["PER_CONTAINER", "FLAT"]; + // Container-only service — per returned container, per wagon, or flat. + return ["PER_CONTAINER", "PER_WAGON", "FLAT"]; case "CANCELLATION": return ["FLAT", "PER_INVOICE"]; case "CUSTOMS_CLEARANCE": - // Flat per clearance (ONE_TIME) / per shipment request (GENERAL). - return ["FLAT"]; case "LASHING": - // Flat cargo-securing fee, billed once per booking. - return ["FLAT"]; + // Per cargo kind: container fees per box/wagon, bulk per ton/wagon. + return cargoKind === "BULK" + ? ["PER_TON", "PER_WAGON"] + : ["PER_CONTAINER", "PER_WAGON"]; case "CONSOLIDATION": case "SHIPPING_LINE": case "PIL_EXTRA_FEE": @@ -258,7 +261,11 @@ const rateUnitOptions = (values: Record) => { const appliesTo = String(values.appliesTo ?? ""); const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS"; if (!appliesTo) return []; - return allowedRateUnits(appliesTo, trigger).map(unitOption); + return allowedRateUnits( + appliesTo, + trigger, + String(values.cargoKind ?? ""), + ).map(unitOption); }; const CURRENCIES = [ @@ -659,7 +666,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ filters: { appliesTo: "OTHER", trigger: - "HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE", + "HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE", }, }, ], @@ -717,6 +724,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ (String(v.appliesTo ?? "") === "OTHER" && ["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))), }, + // ── Cargo kind — customs clearance and lashing are priced separately + // for containers (one rate per container type) and bulk ──────────────── + { + name: "cargoKind", + label: "Cargo kind", + type: "select", + required: true, + options: INTERCITY_KINDS, + placeholder: "Is this fee for containers or bulk?", + description: + "Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.", + showIf: (v) => + v.appliesTo === "OTHER" && + ["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")), + // Not a stored column: a container fee carries its containerTypeId, a + // bulk fee carries none. + getInitialValue: (record) => + record.containerTypeId ? "CONTAINER" : "BULK", + }, + // ── Container type — a container fee names the type it covers ───────── + { + name: "containerTypeId", + label: "Container type", + type: "select", + required: true, + placeholder: "Which container type this fee covers", + showIf: (v) => + v.appliesTo === "OTHER" && + ["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) && + v.cargoKind === "CONTAINER", + }, // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─ { name: "intercityKind", diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 0711a4cf2..8a6edbdee 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [ "CONTRACT_ACTIVE", "CONTRACT_CLOSED", // Post counter-sign document-clearance gate. - "AWAITING_CLEARANCE_PAYMENT", "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY", diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 019f83143..86c37585c 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -14,7 +14,6 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { api } from "@/services/api"; import { ContractClearanceAction } from "./ContractClearanceAction"; @@ -70,17 +69,6 @@ export function ContractCustomerAction({ ); } - if (action.type === "pay-clearance") { - return ( - - ); - } - if (action.type === "initiate") { return ( - invoicesService.listForSource(payItemSource, payItem!.targetId), + invoicesService.listForSource("booking", payItem!.targetId), enabled: payItem !== null, }); const payableInvoiceId = payItemInvoices.find((inv) => @@ -188,7 +183,6 @@ export function ActionNeededSection({ navigate(`/contracts/${item.targetId}`); break; case "pay": - case "clearance-fee": setPayItem(item); break; case "sign": @@ -284,9 +278,7 @@ export function ActionNeededSection({ > {item.kind === "pay" ? "Pay now" - : item.kind === "clearance-fee" - ? "Pay clearance fee" - : item.kind === "duty" + : item.kind === "duty" ? "Pay duty & upload slip" : item.kind === "sign" ? "Sign" diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index b67b11315..7be5a0aed 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -165,19 +165,6 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, - AWAITING_CLEARANCE_PAYMENT: { - stage: 3, - icon: Wallet, - iconColor: "edr-amber-text", - tile: "edr-amber-soft", - hint: "Clearance service fee due · pay to unlock document upload", - step: "edr-accent", - badgeLabel: "Clearance fee due", - badgeBg: "edr-amber-soft", - badgeText: "edr-amber-text", - badgeDot: "edr-accent", - action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight }, - }, AWAITING_DOCUMENTS: { stage: 3, icon: FileUp, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ce218f84b..ce4a2dbea 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,4 +1,4 @@ -import { Group, Paper, Tabs, Text } from "@mantine/core"; +import { Group, Tabs } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { useState } from "react"; @@ -12,7 +12,6 @@ import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; -import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { DocumentsTab } from "./components/DocumentsTab"; @@ -143,8 +142,6 @@ export function ReadonlyBookingView({ const isCustoms = Boolean(booking.customsClearingEnabled); const canSelfRebook = !isCustoms; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; - // Prepaid clearance service fee gate — document upload stays locked until paid. - const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT"; const isClearance = [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", @@ -243,28 +240,6 @@ export function ReadonlyBookingView({
- {isAwaitingClearanceFee && ( - - -
- - Customs clearance service fee due - - - Pay the clearance service fee to unlock the clearance - document upload. Global Logistics starts working on your - shipment once the fee is settled. - -
- -
-
- )} - {isClearance && } 0) { const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder); const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id; - const feeActive = status === "AWAITING_CLEARANCE_PAYMENT"; const delivered = ["COMPLETED", "DELIVERED"].includes(status); const steps: JourneyStep[] = [ { key: "booked", label: "Booking initiated", state: "done" }, - { - key: "fee", - label: "Clearance fee paid", - state: feeActive ? "active" : "done", - owner: "CUST", - }, ...sorted.map((m) => ({ key: m.id, label: m.milestoneLabel, @@ -71,7 +64,7 @@ export function buildJourneySteps( ? "done" : m.status === "SKIPPED" ? "skipped" - : !feeActive && m.id === firstPendingId + : m.id === firstPendingId ? "active" : "idle", })), @@ -79,7 +72,7 @@ export function buildJourneySteps( ]; // Every known milestone is done but the booking hasn't closed yet — the // delivery step is what's in progress. - if (!feeActive && !firstPendingId && !delivered) { + if (!firstPendingId && !delivered) { steps[steps.length - 1].state = "active"; } return steps; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx index f6df38e9b..ec9b8c509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx @@ -3,7 +3,6 @@ import { useDisclosure } from "@mantine/hooks"; import { AlertCircle, ArrowRight, - CreditCard, PackagePlus, PencilLine, Upload, @@ -12,7 +11,6 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal"; import { BookingActionModal } from "./BookingActionModal"; @@ -25,7 +23,6 @@ const ICON_BY_KIND: Record< BookingActionKind, typeof Upload > = { - PAY_CLEARANCE: CreditCard, UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, @@ -59,19 +56,6 @@ export function BookingActionButton({ if (!isChangesRequested && !action) return null; - // The prepaid clearance service fee has its own payment flow (method modal + - // provider redirect) — delegate to the self-contained pay button. - if (action?.kind === "PAY_CLEARANCE") { - return ( - - ); - } - const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; // BOOK navigates to the booking form (cargo + day + window check) — the diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 2bdd976a9..1ebb047a6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -7,7 +7,6 @@ import type { Freight } from "@edr/types"; * to operation. */ export type BookingActionKind = - | "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed @@ -24,11 +23,6 @@ export interface BookingNextAction { } const ACTION_BY_STATUS: Record = { - AWAITING_CLEARANCE_PAYMENT: { - kind: "PAY_CLEARANCE", - label: "Pay clearance fee", - title: "Pay the clearance service fee", - }, AWAITING_DOCUMENTS: { kind: "UPLOAD_DOCUMENTS", label: "Upload documents", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx deleted file mode 100644 index 54a7e7d9d..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Button, type ButtonProps } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard } from "lucide-react"; -import { useState } from "react"; - -import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; -import { isPayable } from "@/pages/billing/invoice-ui"; -import { api } from "@/services/api"; -import { invoicesService } from "@/services/invoices.service"; -import { - paymentsService, - type PaymentMethod, -} from "@/services/payments.service"; -import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; - -/** - * Payment flow for the prepaid customs clearance service fee. The fee is its - * own `clearance`-source invoice — sourceId is the contract id (ONE_TIME, - * contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL - * shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it - * unlocks the clearance document upload; same modal + provider redirect as - * booking payment. - */ -export function useClearanceFeePayment(sourceId: string) { - const [modalOpen, setModalOpen] = useState(false); - - const { data: invoices = [] } = useQuery({ - queryKey: ["clearance-invoices", sourceId], - queryFn: () => invoicesService.listForSource("clearance", sourceId), - enabled: Boolean(sourceId), - }); - const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null; - - const mutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payableInvoice) { - throw new Error( - "No payable clearance-fee invoice found yet. Please refresh or contact support.", - ); - } - return api.invoices.pay.call({ - id: payableInvoice.id, - payload: { method, platform: "web" }, - }); - }, - onSuccess: (data, method) => { - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ - invoiceId: payableInvoice!.id, - method, - }); - window.location.href = redirectUrl; - }, - }); - - const close = () => { - if (!mutation.isPending) { - setModalOpen(false); - mutation.reset(); - } - }; - - return { - invoice: payableInvoice, - modalOpen, - open: () => setModalOpen(true), - close, - processing: mutation.isPending, - error: mutation.isError - ? mutation.error instanceof Error - ? mutation.error.message - : "Could not start payment. Please try again." - : null, - confirm: (method: PaymentMethod) => mutation.mutate(method), - }; -} - -interface PayClearanceFeeButtonProps { - /** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */ - sourceId: string; - /** Fallback currency while the invoice is loading. */ - currency?: string; - label?: string; - size?: ButtonProps["size"]; - fullWidth?: boolean; -} - -/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */ -export function PayClearanceFeeButton({ - sourceId, - currency, - label = "Pay clearance fee", - size = "xs", - fullWidth, -}: PayClearanceFeeButtonProps) { - const pay = useClearanceFeePayment(sourceId); - - return ( - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f6c3098a0..aff1526a6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -438,9 +437,6 @@ export default function ContractDetailPage() { // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; - // Prepaid clearance service fee gate (Path B) — the document step stays - // locked until the fee invoice settles. - const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT"; return ( @@ -574,13 +570,6 @@ export default function ContractDetailPage() { Global Logistics is creating your booking )} - {awaitingClearanceFee && ( - - )} {canUploadClearance && (