mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
feat(freight-api): add DJF as a supported currency
Adds DJF to freight.payments_currency_enum (migration 3860000000000, alone in its own migration per Postgres's ADD VALUE-in-a-transaction restriction) and to manual_payment_settings (djf_enabled column, migration 3870000000000, default true). Replaces the binary ETB/USD assumptions that would have silently mispriced or discarded a DJF booking: - booking-pricing / contract-pricing: usdToEtb scalar -> a rate table keyed by source currency (ExchangeService.getRateTable), so a contract-frozen rate converts into whatever currency the booking is paid in instead of being dropped when neither leg is ETB or USD. - warehouse-fee / booking-wagon-cancellation: normalizeCurrency no longer coerces anything non-ETB to USD. - additional-charge: convertAmount no longer bails out for a currency that isn't literally ETB or USD. - manual-payment-settings: isEnabled/enabledCurrencies cover DJF. Widens the three @IsIn(['ETB','USD']) DTO validators, and adds DJF to the export/report currency filter option lists. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
@@ -3,7 +3,7 @@ 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 { CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
@@ -95,9 +95,9 @@ export class ContractPricingService {
|
||||
(r) => !r.shippingLineCompanyId,
|
||||
);
|
||||
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);
|
||||
const usdToTarget =
|
||||
currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode);
|
||||
const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget));
|
||||
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
@@ -98,7 +98,7 @@ export class CreateBookingRequestDto {
|
||||
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['ETB', 'USD'])
|
||||
@IsIn(['ETB', 'USD', 'DJF'])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
||||
const frozenByCode = (
|
||||
snap: ContractRateSnapshot | null,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): ContractRateSnapshot | null =>
|
||||
(
|
||||
BookingPricingService.prototype as unknown as {
|
||||
@@ -28,14 +28,14 @@ const frozenByCode = (
|
||||
m: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
) => ContractRateSnapshot | null;
|
||||
}
|
||||
).frozenRateByCode(
|
||||
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||
'CONTAINER_20FT',
|
||||
bookingCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
|
||||
describe('per-shipment billing currency', () => {
|
||||
@@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => {
|
||||
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
||||
// The old behaviour returned null here, which silently re-priced the
|
||||
// booking at live rates and lost the agreed contract price.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000);
|
||||
});
|
||||
|
||||
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
||||
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
|
||||
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400);
|
||||
});
|
||||
|
||||
it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => {
|
||||
// 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040);
|
||||
});
|
||||
|
||||
it('passes a matching-currency snapshot through untouched', () => {
|
||||
const snap = snapshot('USD', 400);
|
||||
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
|
||||
expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap);
|
||||
});
|
||||
|
||||
it('refuses to price off an unusable exchange rate', () => {
|
||||
// Converting with 0 would zero the whole line.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to price off a currency the rate table has no entry for', () => {
|
||||
expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no snapshot', () => {
|
||||
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
||||
expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user