From ff3cee12e316b759daa75aa67fcde5bc5c724cad Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 13:47:53 +0000 Subject: [PATCH] feat: fuel surcharge per lane and cargo type --- .../contract-rate-schedule.builder.ts | 15 ++++++++++--- .../contracts/contract-pricing.service.ts | 17 ++++++++------ .../rule-engine/rule-engine.service.spec.ts | 9 +++++--- .../rule-engine/rule-engine.service.ts | 22 ++++++++++++------- 4 files changed, 42 insertions(+), 21 deletions(-) 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 c644d6117..2f1991df2 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 @@ -193,17 +193,26 @@ export class ContractRateScheduleBuilder { return rate.tradeDirection === want; } - /** Fuel row — the lane matters, so it rides along in the charge label. */ + /** + * Fuel row — the lane matters, so it rides along in the charge label. + * Per-liter collapses to one flat total (base liters × rate value); the + * customer only ever sees the final price. + */ private fuelRow(rate: Rate): RateScheduleRow { const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; const destination = rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + const perLiter = rate.rateUnit === 'PER_LITER'; return { route: `Fuel surcharge (${origin} → ${destination})`, cargo: this.cargoLabel(rate), currency: rate.currency, - amount: this.formatAmount(rate.rateValue), - unit: this.unitLabel(rate.rateUnit), + amount: this.formatAmount( + perLiter + ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue) + : rate.rateValue, + ), + unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit), }; } 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 3f49b127f..af0a149d7 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 @@ -302,15 +302,18 @@ export class ContractPricingService { r.cargoTypeId === scope.cargoTypeId, ); if (fuel && Number(fuel.rateValue) > 0) { - const base = Number(fuel.baseLiters ?? 0); + // Per-liter collapses to one flat total (base liters × rate value) — + // the customer only sees the final price, and booking pricing bills + // the same flat figure once (see RuleEngineService.fuelCharges). + const perLiter = fuel.rateUnit === 'PER_LITER'; + const total = perLiter + ? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue) + : Number(fuel.rateValue); lineItems.push({ code: 'FUEL_SURCHARGE', - label: - fuel.rateUnit === 'PER_LITER' - ? `Fuel surcharge (${scope.cargoType.cargoTypeName}, ${base} liters)` - : `Fuel surcharge (${scope.cargoType.cargoTypeName})`, - unit: toContractUnit(fuel.rateUnit), - unitPrice: convert(Number(fuel.rateValue)), + label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`, + unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit), + unitPrice: convert(total), cargoTypeCode: scope.cargoType.code ?? null, conditionalOn: 'has_fuel', }); 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 440616298..281f2d1b2 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 @@ -475,13 +475,16 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { const fuelMods = (result: Awaited>) => result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE'); - it('PER_LITER bills base liters × rate value once, regardless of wagons', async () => { + it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => { const result = await buildService([fuelPerLiter]).evaluate(fuelInput()); const mods = fuelMods(result); expect(mods).toHaveLength(1); - expect(mods[0].triggerValue).toBe(100); + // Flat: the customer sees only the total, and a frozen contract snapshot + // (also stored flat) multiplies it by quantity 1 — never by the liters. + expect(mods[0].triggerValue).toBe(1); + expect(mods[0].unitPriceUsd).toBe(200); expect(mods[0].calculatedAmount).toBe(200); - expect(mods[0].billingUnit).toBe('PER_LITER'); + expect(mods[0].billingUnit).toBe('FLAT'); }); it('PER_WAGON bills the wagons the cargo occupies', async () => { 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 a290aeb33..908d00ecc 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 @@ -648,9 +648,12 @@ export class RuleEngineService { /** * Fuel surcharge — fires when the booking's cargo type has hasFuel = true, * billed off the FUEL rate matching the booking's lane (trade direction + - * origin + destination) and cargo type. PER_LITER bills baseLiters × - * rateValue once per booking; PER_WAGON bills the wagons the cargo occupies. - * No matching lane rate simply bills nothing — same leniency as lashing. + * origin + destination) and cargo type. PER_LITER collapses to one FLAT + * amount (baseLiters × rateValue, once per booking) — the customer only ever + * sees the total, and the frozen contract snapshot stores that same flat + * figure so the snapshot-override math bills it exactly once. PER_WAGON + * bills the wagons the cargo occupies. No matching lane rate simply bills + * nothing — same leniency as lashing. */ private fuelCharges( input: BookingEvaluationInput, @@ -673,9 +676,12 @@ export class RuleEngineService { 0, Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), ); - const billedQty = - rate.rateUnit === 'PER_LITER' ? Number(rate.baseLiters ?? 0) : wagons; - const amount = billedQty * rateValue; + const perLiter = rate.rateUnit === 'PER_LITER'; + const billedQty = perLiter ? 1 : wagons; + const unitPrice = perLiter + ? Number(rate.baseLiters ?? 0) * rateValue + : rateValue; + const amount = billedQty * unitPrice; if (!(amount > 0)) return modifiers; modifiers.push({ rateId: rate.id, @@ -683,8 +689,8 @@ export class RuleEngineService { triggerValue: billedQty, calculatedAmount: amount, currency: rate.currency, - unitPriceUsd: rateValue, - billingUnit: rate.rateUnit, + unitPriceUsd: unitPrice, + billingUnit: perLiter ? 'FLAT' : rate.rateUnit, }); return modifiers; }