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(