diff --git a/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts new file mode 100644 index 000000000..8d0f52110 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds DJF to `freight.payments_currency_enum` — the only currency column in + * the schema backed by a real Postgres enum (every other currency column is + * a plain varchar and needed no migration). + * + * This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD + * VALUE` cannot be used within the same transaction that added it (Postgres + * restriction, still true on PG 12+), and migrations here run one-per- + * transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert + * that writes 'DJF' into `payments.currency` to this file. + */ +export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface { + name = 'AddDjfPaymentsCurrency3860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`); + } + + public async down(): Promise { + // Postgres cannot drop a single enum value. Reverting would require + // recreating the type and every dependent column/constraint — out of + // scope for a currency addition; leave it in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts new file mode 100644 index 000000000..6353e8664 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the DJF toggle to `manual_payment_settings`, alongside the existing + * `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF + * invoices are bank-transfer-settleable from day one. + */ +export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface { + name = 'AddDjfManualPaymentSetting3870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings + ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index fa00fb521..6d416a7e3 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -126,7 +126,7 @@ export class FilterInvoiceDto { @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) - @IsIn(["USD", "ETB"]) + @IsIn(["ETB", "USD", "DJF"]) currency?: "USD" | "ETB"; @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index 012d5f8db..86cad4277 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,7 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -291,20 +291,24 @@ export class AdditionalChargeService { } /** - * Amount converted to the other of ETB/USD, via the existing shared + * Amount converted to a second reference currency, via the existing shared * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` * rate) — same mechanism `booking-wagon-cancellation.service.ts` and - * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * warehouse fee pricing already use. ETB converts to USD and vice versa + * (unchanged behaviour); any other supported currency (DJF) converts to + * USD, the system's pivot currency. Null on an unsupported currency, or if * the rate feed is down — this is a display convenience, not the payable * amount, so a failure here must never break the charge list. */ private async convertAmount( charge: AdditionalCharge, ): Promise<{ amount: number; currency: string } | null> { - if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; - const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + const from = charge.currency?.toUpperCase(); + if (!(CURRENCY_CODES as readonly string[]).includes(from ?? '')) return null; + const source = from as CurrencyCode; + const target: CurrencyCode = source === 'ETB' ? 'USD' : source === 'USD' ? 'ETB' : 'USD'; try { - const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + const amount = await this.exchangeService.convert(Number(charge.amount), source, target); return { amount: Math.round(amount * 100) / 100, currency: target }; } catch (err) { this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); 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..742088da9 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 @@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let exchangeService: { getRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; @@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => { }; exchangeService = { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + // Delegates to `getRate` so a test that reassigns + // `exchangeService.getRate.mockResolvedValue(...)` gets a consistent + // rate table without also having to touch this mock. + getRateTable: jest.fn(async (target: string) => { + const rate = await exchangeService.getRate('USD', target); + return { ETB: rate, USD: rate, DJF: rate }; + }), }; service = new BookingPricingService( @@ -324,7 +331,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, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -572,7 +579,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, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -707,7 +714,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, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } 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..e654889eb 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 @@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -143,8 +143,9 @@ 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 isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; // 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,7 +214,7 @@ 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); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -570,8 +571,9 @@ 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 isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; const isBulk = booking.freightType === 'BULK'; const rateType = @@ -608,7 +610,7 @@ export class BookingPricingService { frozenRates, container.containerTypeId, paymentCurrency, - usdToEtb, + fx, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -698,7 +700,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, fx) : null; let amount: number; let unitAmount: number; @@ -771,8 +773,9 @@ 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 isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; const containerCount = evalInput.containers.reduce( (sum, c) => sum + Number(c.quantity || 0), @@ -824,7 +827,7 @@ export class BookingPricingService { frozenRates, leg.rateType, paymentCurrency, - usdToEtb, + fx, ); let amount: number; let unitAmount: number; @@ -1021,13 +1024,18 @@ export class BookingPricingService { * drifted to.) Grandfathered ETB contracts convert the other way for the same * reason. * + * `fx` is a rate table converting FROM each source currency INTO the + * booking's currency (see `ExchangeService.getRateTable`) — a snapshot can + * be frozen in USD or (grandfathered) ETB, and the booking can be paid in + * any supported currency, so a scalar USD→ETB rate is no longer enough. + * * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; @@ -1035,15 +1043,11 @@ 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; + // A rate of 0/NaN (an unpriced or unsupported source currency) would + // silently zero the price. + const rate = fx[snap.currency]; + if (!(rate > 0)) return null; + const converted = round2(unitPrice * rate); // A copy — the snapshot rows are shared across the pricing pass. return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { @@ -1061,7 +1065,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -1071,7 +1075,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, fx); } /** @@ -1093,9 +1097,9 @@ 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 fx = await this.exchangeService.getRateTable(currency as CurrencyCode); + const usdToEtb = fx['USD']; + const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToEtb)); // 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 +1136,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, fx); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -1161,7 +1165,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, fx) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1196,7 +1200,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, fx); 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 983564a51..a35e29663 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 @@ -8,7 +8,7 @@ import { NotFoundException, } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -114,6 +114,15 @@ interface PricedFee { * The cycle is repeatable by construction: the rebooked booking is a normal * PAID booking, so it can itself be partially cancelled again. */ + +/** Validates a stored currency string against the supported set, defaulting to USD. */ +function toCurrencyCode(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; +} + @Injectable() export class BookingWagonCancellationService { private readonly logger = new Logger(BookingWagonCancellationService.name); @@ -1697,10 +1706,10 @@ 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; a + // non-USD booking converts) — same conversion booking pricing applies. + const target = toCurrencyCode(booking.paymentCurrency); + const from = toCurrencyCode(raw.currency); if (from === target) return raw; const fx = await this.exchangeService.getRate(from, target); return { 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 04be6460f..1367e8c8a 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 @@ -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); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 9f596bbef..c0d4e65ee 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -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() 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..c6edd436f 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 @@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => const frozenByCode = ( snap: ContractRateSnapshot | null, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null => ( BookingPricingService.prototype as unknown as { @@ -28,14 +28,14 @@ const frozenByCode = ( m: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ) => 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(); }); }); diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts index 51e99614a..d3b24dd43 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = { { key: 'paymentCurrency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'serviceTypeId', label: 'Service type', type: 'text' }, // Routes are one-to-many on contract_routes, so these filter via EXISTS diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index d4d635f6f..4afe28771 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'minAmount', label: 'Min total', type: 'text' }, { key: 'maxAmount', label: 'Max total', type: 'text' }, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts index 59739823c..e7a9963f2 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts @@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'search', label: 'Search order or transaction ID', type: 'text' }, ], diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 971de03cb..39d4757e7 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() usdEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" }) + @IsOptional() + @IsBoolean() + djfEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index a18f97279..862456241 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "usd_enabled", type: "boolean", default: true }) usdEnabled!: boolean; + /** Manual settlement allowed for DJF invoices. */ + @Column({ name: "djf_enabled", type: "boolean", default: true }) + djfEnabled!: boolean; + /** IAM user id of the last operator to change either toggle. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index efb43fa91..dc397cf79 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -4,16 +4,23 @@ import { Repository } from "typeorm"; import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; -/** The two currencies an invoice can be settled by hand in. */ -export type ManualPaymentCurrency = "ETB" | "USD"; +/** The currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD" | "DJF"; + +const FIELD_BY_CURRENCY: Record = { + ETB: "etbEnabled", + USD: "usdEnabled", + DJF: "djfEnabled", +}; /** * Owns the single `manual_payment_settings` row: whether Finance may settle * invoices by hand, per currency. * * Defaults mirror how the platform behaved before the toggles existed — USD - * has always been bank-transfer-only so it starts ON; ETB manual settlement is - * the new capability and starts OFF, so enabling it is a deliberate act. + * and DJF have always been bank-transfer-capable so they start ON; ETB manual + * settlement is the new capability and starts OFF, so enabling it is a + * deliberate act. */ @Injectable() export class ManualPaymentSettingsService { @@ -30,7 +37,7 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true }), + this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }), ); } @@ -40,31 +47,34 @@ export class ManualPaymentSettingsService { const enabled: ManualPaymentCurrency[] = []; if (setting.etbEnabled) enabled.push("ETB"); if (setting.usdEnabled) enabled.push("USD"); + if (setting.djfEnabled) enabled.push("DJF"); return enabled; } /** Whether one currency may be settled by hand right now. */ async isEnabled(currency: string | null | undefined): Promise { const upper = currency?.toUpperCase(); - if (upper !== "ETB" && upper !== "USD") return false; + const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency]; + if (!field) return false; const setting = await this.get(); - return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + return setting[field]; } - /** Flip either toggle; an omitted field leaves that currency unchanged. */ + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, updatedById?: string | null, ): Promise { const current = await this.get(); await this.repository.update(current.id, { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, ); return updated; } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 0cf3b886c..b5cdf582f 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" -type Currency = "ETB" | "USD" +type Currency = "ETB" | "USD" | "DJF" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @Entity({ schema: 'freight', name: 'payments' }) @@ -25,7 +25,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) method!: PaymentMethod - @Column({ type: "enum", enum: ["ETB", "USD"] }) + @Column({ type: "enum", enum: ["ETB", "USD", "DJF"] }) currency!: Currency @Column({ type: "numeric" }) diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 550475599..d50b20e9c 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = { options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ], }; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 6d7084a96..6c22d37b5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -14,7 +14,7 @@ export class GenerateInvoiceDto { @ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) billingCurrency?: 'ETB' | 'USD'; } 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 c59cd0617..80a81b13a 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 @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; @@ -430,8 +430,11 @@ export class WarehouseFeeService { }; } - private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { - return currency === 'ETB' ? 'ETB' : 'USD'; + private normalizeCurrency(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise {