From df221a873d2d1e1b1d6cd9f289d492fefa5c96aa Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 29 Aug 2026 08:13:07 +0000 Subject: [PATCH] refactor(pricing): price in the booking's currency, whatever it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pricing carried one number, usdToEtb, gated on a boolean: const isEtbBooking = paymentCurrency === 'ETB'; const usdToEtb = isEtbBooking ? await getRate('USD', 'ETB') : 1; Four entry points repeated it, and every conversion was a ternary on that boolean. A third currency has nowhere to go in that shape. Replaced with a USD-to-x rate map resolved once per pass, and a small moneyIn() helper returning the two conversions the line builders actually use. A USD booking stays the identity case and is left unrounded exactly as before — rounding it now would shift totals on bookings this change is not meant to touch. Everything else rounds to its own currency's precision, which is what makes DJF come out in whole francs. frozenRateByCode took the same scalar and hardcoded USD to ETB and ETB to USD, returning null for any other pair — which would have silently dropped an agreed contract price on a DJF booking and re-priced it at live rates, the exact bug the USD/ETB conversion was added to fix. It now crosses the two USD legs, so any pair converts, and keeps the guard that returns null on a missing or 0/NaN rate rather than zeroing a line. booking-wagon-cancellation collapsed anything that was not ETB to USD: const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; That billed a DJF booking's cancellation fee in dollars. It now uses the booking's own currency, narrowed against the shared list. warehouse-fee's normalizeCurrency did the same collapse, and rounded conversions to 2dp regardless of target. Invoice totals and the gateway amount round by currency too — a fractional franc is malformed, not precise. round2 is left alone everywhere it already serves ETB and USD. --- .../src/modules/billing/billing.service.ts | 13 +- .../bookings/booking-pricing.service.spec.ts | 36 ++++-- .../bookings/booking-pricing.service.ts | 114 ++++++++++-------- .../booking-wagon-cancellation.service.ts | 26 +++- .../contracts/contract-pricing.service.ts | 10 +- .../contracts/shipment-currency.spec.ts | 24 +++- .../warehouses/warehouse-fee.service.ts | 15 ++- 7 files changed, 160 insertions(+), 78 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 1dd4c5656..83d47b815 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -45,6 +45,7 @@ import { settlementReferences, } from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; +import { roundMoney } from "@edr/types"; /** Options forwarded to the payment gateway when settling an invoice. */ export interface PayInvoiceOptions { @@ -1414,11 +1415,13 @@ export class BillingService { companyId: input.companyId ?? null, companyProfileId: input.companyProfileId ?? null, shippingLineCompanyId: input.shippingLineCompanyId ?? null, - subtotalAmount: round2(subtotalAmount), - taxAmount: round2(taxAmount), - totalAmount: round2(totalAmount), + // Rounded to the invoice currency's own precision: DJF has no centimes, so a + // fractional total is malformed and CAC Bank rejects it outright. + subtotalAmount: roundMoney(subtotalAmount, currency), + taxAmount: roundMoney(taxAmount, currency), + totalAmount: roundMoney(totalAmount, currency), paidAmount: 0, - balanceAmount: round2(totalAmount), + balanceAmount: roundMoney(totalAmount, currency), payments: [], currency, status, @@ -2132,7 +2135,7 @@ export class BillingService { // matches the debited amount to the cent (amountsMatchToTheCent), so any // rounding here would overcharge the payer and leave the invoice balance // non-zero. billQuery quotes the same unrounded value. - amountMinor: round2(Number(invoice.balanceAmount)), + amountMinor: roundMoney(Number(invoice.balanceAmount), invoice.currency), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", 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 ba1aaa875..d0a3f6738 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 @@ -10,6 +10,30 @@ const MOJO = 'yard-mojo'; const DIRE = 'yard-dire-dawa'; const LEBU = 'yard-lebu'; +/** + * Pricing resolves every USD→x rate in one call now, so the stub derives the map from the + * same `getRate` mock the specs already tweak — a test that changes the rate still changes + * what pricing sees. + */ +type ExchangeStub = { getRate: jest.Mock; getRatesFromUsd: jest.Mock }; + +const exchangeStub = (rate: number = MOCK_CBE_RATE): ExchangeStub => { + const getRate = jest.fn().mockResolvedValue(rate); + return { + getRate, + getRatesFromUsd: jest.fn(async (codes: readonly string[]) => + Object.fromEntries( + await Promise.all( + codes.map(async (code) => [ + code, + code === 'USD' ? 1 : await getRate('USD', code), + ]), + ), + ), + ), + }; +}; + describe('BookingPricingService — domestic corridor', () => { const intercityBulkUsd: Rate = { id: 'rate-intercity-bulk-usd', @@ -38,16 +62,14 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let exchangeService: { getRate: jest.Mock }; + let exchangeService: ExchangeStub; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; ratesService = { findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), }; - exchangeService = { - getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), - }; + exchangeService = exchangeStub(); service = new BookingPricingService( bookingsRepository as never, @@ -324,7 +346,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking })), } as never, { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + exchangeStub() as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -572,7 +594,7 @@ describe('BookingPricingService — bulk base freight units', () => { } as never, { findById: jest.fn() } as never, { findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + exchangeStub() as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -707,7 +729,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { })), } as never, { findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + exchangeStub() as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, 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 29104bbce..936e8d11f 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,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -25,6 +25,7 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { ContainerValidationService } from './container-validation.service'; +import { PAYMENT_CURRENCIES, roundMoney } from '@edr/types'; /** * One physical container over its VGM limit. Weight limits are per container, @@ -143,8 +144,8 @@ export class BookingPricingService { const ruleResult = await this.ruleEngineService.evaluate(evalInput); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const usdTo = await this.exchangeService.getRatesFromUsd(PAYMENT_CURRENCIES); + const { fromUsd, money } = this.moneyIn(paymentCurrency, usdTo); // H15: a booking created under a contract prices from that contract's FROZEN // rate snapshots (the agreed rates), not the live rate of the day. Loaded @@ -213,19 +214,11 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); - const unitAmount = frozen - ? Number(frozen.unitPrice) - : isEtbBooking - ? round2(unitUsd * usdToEtb) - : unitUsd; + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdTo); + const unitAmount = frozen ? Number(frozen.unitPrice) : fromUsd(unitUsd); const convertedAmount = frozen - ? isEtbBooking - ? round2(unitAmount * quantity) - : unitAmount * quantity - : isEtbBooking - ? round2(usdAmount * usdToEtb) - : usdAmount; + ? money(unitAmount * quantity) + : fromUsd(usdAmount); const item: PriceLineItemDto = { code: mod.surchargeCode, @@ -570,8 +563,8 @@ export class BookingPricingService { }> { const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const usdTo = await this.exchangeService.getRatesFromUsd(PAYMENT_CURRENCIES); + const { fromUsd } = this.moneyIn(paymentCurrency, usdTo); const isBulk = booking.freightType === 'BULK'; const rateType = @@ -608,7 +601,7 @@ export class BookingPricingService { frozenRates, container.containerTypeId, paymentCurrency, - usdToEtb, + usdTo, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -643,8 +636,8 @@ export class BookingPricingService { } else { const unitUsd = Number(rate!.rateValue); const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons); - amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; + amount = fromUsd(usdAmount); + unitAmount = fromUsd(unitUsd); } if (rate) usedRatesMap.set(rate.id, rate); lines.push({ @@ -698,7 +691,7 @@ export class BookingPricingService { const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdTo) : null; let amount: number; let unitAmount: number; @@ -712,8 +705,8 @@ export class BookingPricingService { ); } else { const usdAmount = this.amountForRate(fallback, quantity, wagonCount); - amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd; + amount = fromUsd(usdAmount); + unitAmount = fromUsd(unitUsd); } lines.push({ code: rateType, @@ -771,8 +764,8 @@ export class BookingPricingService { const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const usdTo = await this.exchangeService.getRatesFromUsd(PAYMENT_CURRENCIES); + const { fromUsd, money } = this.moneyIn(paymentCurrency, usdTo); const containerCount = evalInput.containers.reduce( (sum, c) => sum + Number(c.quantity || 0), @@ -824,19 +817,17 @@ export class BookingPricingService { frozenRates, leg.rateType, paymentCurrency, - usdToEtb, + usdTo, ); let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); - amount = isEtbBooking - ? round2(unitAmount * quantity) - : unitAmount * quantity; + amount = money(unitAmount * quantity); } else { const usdAmount = value * quantity; - amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount; - unitAmount = isEtbBooking ? round2(value * usdToEtb) : value; + amount = fromUsd(usdAmount); + unitAmount = fromUsd(value); } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). if (!(amount > 0)) continue; @@ -1010,6 +1001,31 @@ export class BookingPricingService { return byCode; } + /** + * Conversion helpers for one booking's billing currency. + * + * Rates are quoted USD→x, because that is how tariffs are stored. A USD booking is the + * identity case and stays untouched — it never was rounded, and rounding it now would + * shift totals on bookings this change is not supposed to touch. + */ + private moneyIn( + bookingCurrency: string, + usdTo: Record, + ): { fromUsd: (usd: number) => number; money: (amount: number) => number } { + const rate = usdTo[bookingCurrency]; + if (!(rate > 0)) { + throw new BadRequestException( + `No exchange rate is available for ${bookingCurrency}; this booking cannot be priced`, + ); + } + + const identity = bookingCurrency === 'USD'; + const money = (amount: number) => + identity ? amount : roundMoney(amount, bookingCurrency); + + return { fromUsd: (usd: number) => money(usd * rate), money }; + } + /** * The frozen snapshot for a rate code, expressed in the BOOKING's currency. * @@ -1021,13 +1037,14 @@ export class BookingPricingService { * drifted to.) Grandfathered ETB contracts convert the other way for the same * reason. * - * Returns null only when there is no snapshot or its price is unusable. + * Returns null only when there is no snapshot, its price is unusable, or a rate for + * either currency is missing. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + usdTo: Record, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; @@ -1035,15 +1052,13 @@ export class BookingPricingService { if (!(unitPrice >= 0)) return null; if (snap.currency === bookingCurrency) return snap; - // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. - if (!(usdToEtb > 0)) return null; - const converted = - snap.currency === 'USD' && bookingCurrency === 'ETB' - ? round2(unitPrice * usdToEtb) - : snap.currency === 'ETB' && bookingCurrency === 'USD' - ? unitPrice / usdToEtb - : null; - if (converted == null) return null; + // Cross the two USD legs, so any pair of billing currencies converts rather than only + // USD <-> ETB. A missing or 0/NaN rate on either leg would silently zero the price, so + // it returns null and the caller falls back to the live rate instead. + const fromRate = usdTo[snap.currency]; + const toRate = usdTo[bookingCurrency]; + if (!(fromRate > 0) || !(toRate > 0)) return null; + const converted = roundMoney((unitPrice / fromRate) * toRate, bookingCurrency); // A copy — the snapshot rows are shared across the pricing pass. return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { @@ -1061,7 +1076,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, - usdToEtb: number, + usdTo: Record, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -1071,7 +1086,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdTo); } /** @@ -1093,9 +1108,8 @@ export class BookingPricingService { 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 ? round2(usd * usdToEtb) : usd); + const usdTo = await this.exchangeService.getRatesFromUsd(PAYMENT_CURRENCIES); + const { fromUsd: convert } = this.moneyIn(currency, usdTo); // An Ethiopian-side-only customs service prices off its own rate; the // contract froze its snapshots under the matching code prefix. Resolved by @@ -1132,7 +1146,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has(`${customsType}_20FT`) || frozenRates?.has(`${customsType}_40FT`); - const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); + const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdTo); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -1161,7 +1175,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb) + ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdTo) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1196,7 +1210,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); + const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdTo); const live = (booking.cargoTypeId ? onLeg.find( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index ac6fb5aa3..cb8690fe6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -51,6 +51,7 @@ import { CancelledUnitSnapshot, WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; +import { PaymentCurrency, isPaymentCurrency, roundMoney } from '@edr/types'; export { WAGON_CANCEL_FEE_INVOICE_TYPE }; @@ -1480,16 +1481,17 @@ export class BookingWagonCancellationService { */ private async priceFee(booking: Booking, cut: RequestedCut): Promise { const raw = await this.priceFeeInRateCurrency(booking, cut); - // Bill in the booking's own currency (rates are configured in USD; ETB - // bookings pay ETB) — same USD→ETB conversion booking pricing applies. - const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; - const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; + // Bill in the booking's own currency (rates are configured in USD) — the same + // conversion booking pricing applies. These used to collapse anything that was not + // ETB to USD, which billed a DJF booking's cancellation fee in dollars. + const target = asPaymentCurrency(booking.paymentCurrency, 'USD'); + const from = asPaymentCurrency(raw.currency, 'USD'); if (from === target) return raw; const fx = await this.exchangeService.getRate(from, target); return { ...raw, - amount: round2(raw.amount * fx), - perWagon: round2(raw.perWagon * fx), + amount: roundMoney(raw.amount * fx, target), + perWagon: roundMoney(raw.perWagon * fx, target), currency: target, }; } @@ -1974,3 +1976,15 @@ export class BookingWagonCancellationService { }); } } + +/** + * A currency column narrowed to something the exchange service can price, falling back + * when the stored string is not one the platform bills in. + */ +function asPaymentCurrency( + value: string | null | undefined, + fallback: PaymentCurrency, +): PaymentCurrency { + const code = value?.trim().toUpperCase(); + return isPaymentCurrency(code) ? code : fallback; +} 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 0af09a5f8..54d79eae5 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 @@ -2,10 +2,10 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; -import { round2 } from '../billing/invoice-settlement.util'; import { ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; +import { roundMoney } from '@edr/types'; /** A single unit-rate line at contract phase — NO quantities, NO totals. */ export interface ContractUnitRateLineItem { @@ -87,9 +87,11 @@ export class ContractPricingService { async buildBreakdown(contract: Contract): Promise { const liveRates = await this.ratesService.findLiveRates(); const currency = contract.paymentCurrency; - const isEtb = currency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + // Rates are stored in USD. A USD contract is the identity case and stays unrounded, + // exactly as before; anything else converts and rounds to its own precision. + const usdToContract = await this.exchangeService.getRate('USD', currency as never); + const convert = (usd: number): number => + currency === 'USD' ? usd : roundMoney(usd * usdToContract, currency); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts index 7bd109430..8f216a44e 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -17,10 +17,15 @@ const resolveCurrency = (c: Contract, requested?: string | null): string => const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => ({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot; +/** + * `usdToEtb` is now one entry in a USD→x map, so any pair of billing currencies crosses + * through USD rather than only USD <-> ETB. + */ const frozenByCode = ( snap: ContractRateSnapshot | null, bookingCurrency: string, usdToEtb: number, + usdToDjf = 177.721, ): ContractRateSnapshot | null => ( BookingPricingService.prototype as unknown as { @@ -28,14 +33,14 @@ const frozenByCode = ( m: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + usdTo: Record, ) => ContractRateSnapshot | null; } ).frozenRateByCode( snap ? new Map([['CONTAINER_20FT', snap]]) : null, 'CONTAINER_20FT', bookingCurrency, - usdToEtb, + { USD: 1, ETB: usdToEtb, DJF: usdToDjf }, ); describe('per-shipment billing currency', () => { @@ -82,4 +87,19 @@ describe('frozen contract rate in the booking currency', () => { it('returns null when there is no snapshot', () => { expect(frozenByCode(null, 'ETB', 150)).toBeNull(); }); + + it('converts a USD snapshot into DJF, rounded to whole francs', () => { + // DJF is a zero-decimal currency — a fractional franc is malformed, not precise. + expect(frozenByCode(snapshot('USD', 400), 'DJF', 150)?.unitPrice).toBe(71_088); + }); + + it('crosses an ETB snapshot into DJF through USD', () => { + // 60 000 ETB / 150 = 400 USD; 400 x 177.721 = 71 088.4 -> 71 088 DJF. + expect(frozenByCode(snapshot('ETB', 60_000), 'DJF', 150)?.unitPrice).toBe(71_088); + }); + + it('refuses to price a DJF booking when the DJF rate is unusable', () => { + expect(frozenByCode(snapshot('USD', 400), 'DJF', 150, 0)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'DJF', 150, Number.NaN)).toBeNull(); + }); }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 576933c01..23420e80d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -9,6 +9,7 @@ import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { PaymentCurrency, isPaymentCurrency, roundMoney } from '@edr/types'; interface ItemAttributes { arrivedAt: Date | null; @@ -427,16 +428,22 @@ export class WarehouseFeeService { }; } - private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { - return currency === 'ETB' ? 'ETB' : 'USD'; + /** + * A fee rule's or booking's stored currency, narrowed to one the platform bills in. + * Anything unrecognised bills in USD, which is where rules are configured. + */ + private normalizeCurrency(currency?: string | null): PaymentCurrency { + const code = currency?.trim().toUpperCase(); + return isPaymentCurrency(code) ? code : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { const from = this.normalizeCurrency(fromCurrency); const to = this.normalizeCurrency(toCurrency); - if (from === to) return Math.round(amount * 100) / 100; + // Rounded to the *target* currency's precision — a fee billed in DJF has no centimes. + if (from === to) return roundMoney(amount, to); const rate = await this.exchangeService.getRate(from, to); - return Math.round(amount * rate * 100) / 100; + return roundMoney(amount * rate, to); } private calculateTieredAmount(