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/migrations/3880000000000-ExchangeSettingsPerCurrency.ts b/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts new file mode 100644 index 000000000..3677e6269 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `exchange_settings` was a single-row table holding the USD→ETB fallback + * only. Restructures it to one row per currency so DJF (and any future + * currency) gets its own fallback rate, source and sync timestamp instead of + * a parallel column per currency. + */ +export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface { + name = 'ExchangeSettingsPerCurrency3880000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5); + `); + // The single pre-existing row was always the USD→ETB fallback. + await queryRunner.query(` + UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency + ON freight.exchange_settings (currency) WHERE deleted_at IS NULL; + `); + // Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so + // pricing has a usable fallback before the first successful CBE fetch. + await queryRunner.query(` + INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at) + SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF'); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`); + } +} 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/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts index 98e87007c..4343732b7 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -1,13 +1,14 @@ -import { IsNumber, Max, Min } from "class-validator"; +import { IsNumber, Min } from "class-validator"; /** - * Operator-set USD→ETB fallback. Bounded well outside any plausible published - * rate but far short of a fat-fingered magnitude error — this value multiplies - * real invoice amounts whenever CBE is unreachable. + * Operator-set X→ETB fallback for one currency. The upper bound is enforced + * per currency in the controller (see `RATE_BOUNDS`) rather than here, since + * USD's plausible range (~100-300) and DJF's (~0.5-2) differ by two orders of + * magnitude — this value multiplies real invoice amounts whenever CBE is + * unreachable. */ export class UpdateExchangeSettingDto { @IsNumber({ maxDecimalPlaces: 6 }) - @Min(1) - @Max(10_000) + @Min(0.000001) fallbackRate!: number; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts index 1e1f4ad66..e1fc99780 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm"; export type ExchangeFallbackSource = "AUTO" | "MANUAL"; /** - * Single-row table holding the USD→ETB fallback used when the CBE endpoint is - * unreachable. The live CBE rate always wins; this is only consulted on - * failure, and is overwritten by every successful fetch so it tracks the last - * known good rate. + * One row per foreign currency, holding the X→ETB fallback used when the CBE + * endpoint is unreachable for that currency. The live CBE rate always wins; + * this is only consulted on failure, and is overwritten by every successful + * fetch so it tracks the last known good rate. */ @Entity({ schema: "freight", name: "exchange_settings" }) export class ExchangeSetting extends BaseEntity { - /** USD→ETB rate served while the CBE endpoint is failing. */ + /** The foreign currency this row's fallback applies to, e.g. `USD`, `DJF`. */ + @Column({ name: "currency", type: "varchar", length: 5 }) + currency!: string; + + /** currency→ETB rate served while the CBE endpoint is failing for it. */ @Column({ name: "fallback_rate", type: "numeric", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts index fb126f969..a52db2010 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service"; /** * The app's single `ExchangeModule` registration shape: CBE endpoint config - * from `app.cbeExchange`, with the DB-backed fallback wired in. + * from `app.cbeExchange`, with the DB-backed per-currency fallback wired in. * * `ExchangeModule` is registered per-feature-module (bookings, contracts, * warehouses), so this keeps the three call sites identical rather than @@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule { settings: ExchangeSettingsService, ): ExchangeOptions => ({ ...(config.get("app.cbeExchange") ?? {}), - loadFallbackRate: () => settings.loadFallbackRate(), - saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + loadFallbackRate: (code) => settings.loadFallbackRate(code), + saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate), }), }); } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts new file mode 100644 index 000000000..e18bc830e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts @@ -0,0 +1,102 @@ +import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; + +/** + * The CBE feed quotes every currency it publishes against ETB in one fetch — + * this is a fixture of that shape (trimmed to USD + DJF, the two the app + * actually reads). Verified live against the real feed on 2026-09-04. + */ +const CBE_FIXTURE = [ + { + Date: '2026-09-04', + ExchangeRate: [ + { + transactionalSelling: 163.4365, + transactionalBuying: 160.2319, + currency: { CurrencyCode: 'USD' }, + }, + { + transactionalSelling: 0.9203, + transactionalBuying: 0.9022, + currency: { CurrencyCode: 'DJF' }, + }, + // CBE publishes 0 for a currency it isn't quoting cash-selling that + // day — must not be picked up as a usable rate. + { transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } }, + ], + }, +]; + +function mockFetchOnce(payload: unknown): jest.Mock { + const fn = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + (global as unknown as { fetch: typeof fetch }).fetch = fn as never; + return fn; +} + +describe('CbeExchangeProvider — multi-currency', () => { + it('parses every quoted currency out of one fetch, not just USD', async () => { + const fetchMock = mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' }); + const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' }); + + expect(usdToEtb).toBeCloseTo(163.4365); + expect(djfToEtb).toBeCloseTo(0.9203); + // Both rates came from the SAME cached fetch — one HTTP call serves + // every currency, not one per currency. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow( + /No CBE rate available for ZZZ/, + ); + }); + + it('only ever answers for X→ETB — everything else is derived upstream', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull(); + await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull(); + }); +}); + +describe('ExchangeService — USD↔DJF pivot', () => { + it('derives USD→DJF by pivoting through ETB, the provider’s base currency', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('USD', 'DJF'); + + // 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand. + expect(rate).toBeCloseTo(163.4365 / 0.9203, 4); + expect(rate).toBeCloseTo(177.59, 1); + }); + + it('derives the inverse, DJF→USD, from the same pivot', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('DJF', 'USD'); + + expect(rate).toBeCloseTo(0.9203 / 163.4365, 6); + }); + + it('getRateTable resolves every supported currency into the target in one call', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const fx = await service.getRateTable('DJF'); + + expect(fx.DJF).toBe(1); + expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4); + expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 001fc90d2..0e0736561 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { CurrentUser } from "@edr/api-common"; +import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff } from "../../common/booking-guards"; @@ -8,6 +8,31 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; import { ExchangeSettingsService } from "./exchange-settings.service"; +/** + * Sane manual-rate ceiling per currency — bounded well outside any plausible + * published rate but far short of a fat-fingered magnitude error. USD trades + * in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF + * itself is worth roughly 1/177th of a USD). + */ +const RATE_BOUNDS: Record = { + ETB: 1, + USD: 10_000, + DJF: 100, +}; + +const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB"); + +function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] { + const code = currency?.toUpperCase(); + const match = FOREIGN_CURRENCIES.find((c) => c === code); + if (!match) { + throw new BadRequestException( + `Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`, + ); + } + return match; +} + @ApiTags("exchange-settings") @ApiBearerAuth() @Controller("exchange-settings") @@ -17,37 +42,51 @@ export class ExchangeSettingsController { @Get() @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]) @ApiOperation({ - summary: "Current USD→ETB fallback rate and CBE feed health", + summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency", }) - async get() { - const setting = await this.service.get(); - const status = this.service.getFeedStatus(); + async list() { + const settings = await this.service.list(); + const byCurrency = new Map(settings.map((s) => [s.currency, s])); - return { - fallbackRate: setting.fallbackRate, - fallbackSource: setting.fallbackSource, - lastSyncedAt: setting.lastSyncedAt, - updatedById: setting.updatedById, - feed: status, - }; + return FOREIGN_CURRENCIES.map((code) => { + const setting = byCurrency.get(code); + return { + currency: code, + fallbackRate: setting?.fallbackRate ?? null, + fallbackSource: setting?.fallbackSource ?? null, + lastSyncedAt: setting?.lastSyncedAt ?? null, + updatedById: setting?.updatedById ?? null, + feed: this.service.getFeedStatus(code), + }; + }); } - @Patch() + @Patch(":currency") @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: - "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", + "Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)", }) async update( + @Param("currency") currency: string, @Body() dto: UpdateExchangeSettingDto, @CurrentUser() user: TCurrentUser, ) { + const code = assertSupportedCurrency(currency); + if (dto.fallbackRate > RATE_BOUNDS[code]) { + throw new BadRequestException( + `Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`, + ); + } + const updated = await this.service.setManualRate( + code, dto.fallbackRate, user?.id ?? null, ); return { + currency: updated.currency, fallbackRate: updated.fallbackRate, fallbackSource: updated.fallbackSource, lastSyncedAt: updated.lastSyncedAt, diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index e0b670292..df36821bc 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -1,16 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { CurrencyCode } from "@edr/api-common"; import { Repository } from "typeorm"; import { ExchangeSetting } from "./entities/exchange-setting.entity"; /** - * Rate used before the row exists and before the first successful CBE fetch — - * the CBE USD transactional selling rate on 2026-08-04. + * Rate used before a currency's row exists and before its first successful + * CBE fetch. USD is the CBE transactional selling rate on 2026-08-04; DJF is + * the CBE transactional selling rate on 2026-09-04 (CBE started being read + * for DJF then). */ -const SEED_FALLBACK_RATE = 162.4165; +const SEED_FALLBACK_RATES: Partial> = { + USD: 162.4165, + DJF: 0.9203, +}; -/** Health of the CBE feed, as surfaced to the backoffice. */ +/** Health of the CBE feed for one currency, as surfaced to the backoffice. */ export interface ExchangeFeedStatus { /** Rate most recently observed, whatever its source. */ rate: number | null; @@ -22,9 +28,17 @@ export interface ExchangeFeedStatus { lastError: string | null; } +const EMPTY_FEED_STATUS: ExchangeFeedStatus = { + rate: null, + source: null, + lastSuccessAt: null, + lastError: null, +}; + /** - * Owns the single `exchange_settings` row: the USD→ETB fallback used when the - * CBE endpoint is unreachable. + * Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) — + * each holding the currency→ETB fallback used when the CBE endpoint is + * unreachable for it. * * The live CBE rate is always preferred. This value is only read on failure, * and every successful fetch overwrites it, so it tracks the last known good @@ -35,107 +49,113 @@ export class ExchangeSettingsService { private readonly logger = new Logger(ExchangeSettingsService.name); /** - * Feed health, recorded from the exchange provider's callbacks rather than - * read off an injected `ExchangeService`. The provider is registered several - * times (bookings, contracts, warehouses), so no single instance sees every - * fetch — and injecting one here would be circular, since those - * registrations inject *this* service. + * Feed health per currency, recorded from the exchange provider's + * callbacks rather than read off an injected `ExchangeService`. The + * provider is registered several times (bookings, contracts, warehouses), + * so no single instance sees every fetch — and injecting one here would be + * circular, since those registrations inject *this* service. */ - private feed: ExchangeFeedStatus = { - rate: null, - source: null, - lastSuccessAt: null, - lastError: null, - }; + private feed = new Map(); constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} - /** Health of the CBE feed as last observed by any provider instance. */ - getFeedStatus(): ExchangeFeedStatus { - return { ...this.feed }; + /** Health of the CBE feed for `code` as last observed by any provider instance. */ + getFeedStatus(code: CurrencyCode): ExchangeFeedStatus { + return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) }; } - /** The settings row, created at the seed rate on first access. */ - async get(): Promise { - const existing = await this.repository.findOne({ where: {} }); + /** The settings row for `code`, created at the seed rate on first access. */ + async get(code: CurrencyCode): Promise { + const existing = await this.repository.findOne({ where: { currency: code } }); if (existing) return existing; return this.repository.save( this.repository.create({ - fallbackRate: SEED_FALLBACK_RATE, + currency: code, + fallbackRate: SEED_FALLBACK_RATES[code] ?? 1, fallbackSource: "AUTO", lastSyncedAt: null, }), ); } + /** Every currency's settings row, for the backoffice settings list. */ + async list(): Promise { + return this.repository.find({ order: { currency: "ASC" } }); + } + /** - * Reads the stored fallback for the exchange provider. Returns `null` on any - * failure so the provider falls through to its own static default rather - * than propagating a database error into a pricing call. + * Reads the stored fallback for `code`, for the exchange provider. Returns + * `null` on any failure so the provider falls through to its own static + * default rather than propagating a database error into a pricing call. */ - async loadFallbackRate(): Promise { + async loadFallbackRate(code: CurrencyCode): Promise { // Only reached when the live fetch failed, so this call is itself the - // signal that the feed is down. + // signal that the feed is down for this currency. try { - const { fallbackRate } = await this.get(); + const { fallbackRate } = await this.get(code); const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; - this.feed = { - ...this.feed, - rate: usable ? fallbackRate : this.feed.rate, + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { + ...previous, + rate: usable ? fallbackRate : previous.rate, source: "stored", - lastError: this.feed.lastError ?? "CBE endpoint unreachable", - }; + lastError: previous.lastError ?? "CBE endpoint unreachable", + }); return usable ? fallbackRate : null; } catch (err) { const message = (err as Error).message; - this.feed = { ...this.feed, source: "stored", lastError: message }; - this.logger.warn(`Could not read stored exchange fallback: ${message}`); + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { ...previous, source: "stored", lastError: message }); + this.logger.warn( + `Could not read stored exchange fallback for ${code}: ${message}`, + ); return null; } } /** - * Records a freshly fetched live rate as the new fallback. Marked `AUTO`, - * overwriting a manual entry — a manual rate is a stopgap for while CBE is - * down, so a working CBE feed takes precedence again. + * Records a freshly fetched live rate as the new fallback for `code`. + * Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap + * for while CBE is down, so a working CBE feed takes precedence again. */ - async saveFallbackRate(rate: number): Promise { + async saveFallbackRate(code: CurrencyCode, rate: number): Promise { // Only called after a successful fetch, so the feed is confirmed healthy. - this.feed = { + this.feed.set(code, { rate, source: "live", lastSuccessAt: new Date().toISOString(), lastError: null, - }; + }); - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "AUTO", lastSyncedAt: new Date(), updatedById: null, }); - this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); + this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/${code}`); } /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ async setManualRate( + code: CurrencyCode, rate: number, updatedById?: string | null, ): Promise { - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "MANUAL", updatedById: updatedById ?? null, }); this.logger.warn( - `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, + `Exchange fallback for ${code} set manually to ${rate} ETB/${code} by ${updatedById ?? "unknown user"}`, ); - return this.get(); + return this.get(code); } } 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/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 1bd22c7c0..526dbafe6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto { export class OverviewBillingKpisDto { @ApiProperty() revenueMtdEtb!: number; @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() revenueMtdDjf!: number; @ApiProperty() pendingPayments!: number; @ApiProperty() successfulPaymentsMtd!: number; } @@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto { @ApiProperty({ example: '2026-06-01' }) date!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewRecentBookingDto { @@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto { @ApiProperty() bookingsCreated!: number; @ApiProperty() revenueEtb!: number; @ApiProperty() revenueUsd!: number; + @ApiProperty() revenueDjf!: number; @ApiProperty() tons!: number; } @@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto { @ApiProperty() label!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewTonsTrendPointDto { @@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto { @ApiProperty() freightType!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewHeatmapCellDto { diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 6908498bb..d3100df9e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -265,6 +265,7 @@ export class OverviewRepository { async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; + revenueMtdDjf: number; pendingPayments: number; successfulPaymentsMtd: number; }> { @@ -279,6 +280,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueMtdUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueMtdDjf", + ) .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .where("payment.status = :status", { status: "success" }) .andWhere( @@ -298,6 +303,7 @@ export class OverviewRepository { return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; @@ -370,7 +376,7 @@ export class OverviewRepository { days: number, dirs?: string[], offsetDays = 0, - ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -386,6 +392,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, @@ -394,12 +404,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") - .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -510,7 +521,7 @@ export class OverviewRepository { async getPaymentsByMethod( dirs?: string[], ): Promise< - { method: string; count: number; amountEtb: number; amountUsd: number }[] + { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -525,6 +536,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`, + "amountDjf", + ) .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") @@ -533,6 +548,7 @@ export class OverviewRepository { count: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -540,6 +556,7 @@ export class OverviewRepository { count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -580,6 +597,7 @@ export class OverviewRepository { bookingsCreated: number; revenueEtb: number; revenueUsd: number; + revenueDjf: number; tons: number; }> { const bookingScope = directionScopeSql("booking.trade_direction", dirs); @@ -605,13 +623,17 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( windowSql("COALESCE(payment.paid_at, payment.created_at)"), { days, offsetDays }, ) .andWhere(paymentScope.sql, paymentScope.params) - .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(), this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") @@ -626,6 +648,7 @@ export class OverviewRepository { bookingsCreated, revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + revenueDjf: Number(revenueRow?.revenueDjf ?? 0), tons: Number(tonsRow?.tons ?? 0), }; } @@ -634,7 +657,7 @@ export class OverviewRepository { async getRevenueByDirection( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -648,6 +671,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -656,12 +683,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .groupBy("booking.trade_direction") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -669,7 +697,7 @@ export class OverviewRepository { async getRevenueByFreightType( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -683,6 +711,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -691,12 +723,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.freight_type") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -734,6 +767,7 @@ export class OverviewRepository { freightType: string; amountEtb: number; amountUsd: number; + amountDjf: number; }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); @@ -750,6 +784,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -765,6 +803,7 @@ export class OverviewRepository { freightType: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -772,6 +811,7 @@ export class OverviewRepository { freightType: row.freightType, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } 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 { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx index e6eb81a02..50ef7c4be 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -36,7 +36,7 @@ import { downloadBookingFile, fetchViewableFile } from "@/services/files.service import { formatDate, formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; -const CURRENCIES = ["ETB", "USD"]; +const CURRENCIES = ["ETB", "USD", "DJF"]; const STATUS_META: Record = { DRAFT: { label: "Draft", color: "gray" }, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 65c9719ed..678913f78 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Coins, Truck } from "lucide-react"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; @@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => `${Number(amount).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: currencyDecimals(currency), + maximumFractionDigits: currencyDecimals(currency), })} ${currency === "ETB" ? "Birr (ETB)" : currency}`; /** diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx index 318ce3f9b..a03c29bec 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { OperationDatePicker } from "@edr/ui-common"; +import { OperationDatePicker, currencyDecimals } from "@edr/ui-common"; import { api } from "@/auth/http"; import { api as rpc } from "@/services/api"; @@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({ {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} {cancellation.wagonsCancelled} wagon(s) · credit{" "} - {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))} Shipment day diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx index 4bca7cced..4f50c5921 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx @@ -8,6 +8,7 @@ import { api } from "@/auth/http"; import { useAuth } from "@/auth/useAuth"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal"; import { canRebookWagonCancellations, @@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({ {Number(r.wagonsCancelled)} wagon(s) · credit{" "} - {formatMoney(Number(r.creditAmount), r.feeCurrency, 2)} + {formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))} {chip.label} @@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({ Cancelled {formatDate(r.createdAt)} {r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""} {Number(r.feeAmount) > 0 - ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${ + ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}${ r.feePaidAt ? " paid" : " unpaid" }` : ""} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index 047d1361f..fb47ace60 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -39,7 +39,7 @@ import { import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; -const CURRENCIES = ["ETB", "USD"]; +const CURRENCIES = ["ETB", "USD", "DJF"]; const STATUS_META: Record< Freight.ClearanceChargeStatus, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 4925edb7b..308867caa 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -345,7 +345,7 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); // IMPORT bookings pick ETB or USD — starts empty so the choice is // deliberate (required before pricing). Everything else is forced to ETB. - const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">(""); + const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">(""); // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -1144,7 +1144,7 @@ export default function GlCreateBookingForm() { ]); // Only IMPORT actually chooses — the rest bill ETB regardless of the state. - const effectiveCurrency: "USD" | "ETB" = + const effectiveCurrency: "USD" | "ETB" | "DJF" = isImport && paymentCurrency ? paymentCurrency : "ETB"; const currencyError = isImport && !paymentCurrency @@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() { {requestCurrencyLocked ? "The customer chose the billing currency on the shipment request — it cannot be changed." : isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + ? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online." : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 313645db3..ea4ec0cb3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -1554,7 +1554,7 @@ function SecondDutyStep({ /> setCurrency(v ?? "ETB")} size="sm" @@ -2063,7 +2063,7 @@ function DutyStep({ /> setCurrency(v ?? "ETB")} size="sm" diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx index f1a74ac4e..aa5857840 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx @@ -18,7 +18,7 @@ function formatDateLabel(date: string) { return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } -function formatAmount(value: number, currency: "ETB" | "USD") { +function formatAmount(value: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx index e1ceaec1c..3464141fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard"; function formatAmount(amount: number | null, currency: string | null) { if (amount == null) return "—"; - const code = currency === "USD" ? "USD" : "ETB"; return new Intl.NumberFormat("en-US", { style: "currency", - currency: code, + currency: currency || "ETB", maximumFractionDigits: 0, }).format(amount); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx index e076b1f18..307d9fc22 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page"; import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; import { CountUp } from "./CountUp"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, @@ -13,7 +13,7 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") { } /** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */ -function formatCompactCurrency(amount: number, currency: "ETB" | "USD") { +function formatCompactCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx index 52b8bf161..c7e2a1fe5 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx @@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip"; import { OverviewPaymentChart } from "../OverviewPaymentChart"; import { overviewChartColors } from "../overview.styles"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx index 5170b345d..635a78800 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react'; +import { currencyDecimals } from '@edr/ui-common'; import { useAccrualDashboard } from '@/hooks/useWarehouses'; import { warehouseService } from '@/services/warehouse.service'; @@ -15,7 +16,8 @@ const ALERT_META: Record = { }; function money(amount: number, currency: string): string { - return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; + const decimals = currencyDecimals(currency); + return `${amount.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${currency}`; } function freeDaysLabel(row: AccrualDashboardRow): string { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index cb2cbc658..058fc96bd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -113,7 +113,7 @@ function Row({ label, value }: { label: string; value: string }) { /** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { const { toast } = useToast(); - const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD'); + const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD' | 'DJF'>('USD'); const enabledId = opened ? inventoryId ?? undefined : undefined; const { data, isLoading } = useQuery( api.warehouses.feePreview.queryOptions({ @@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa setBillingCurrency(value as 'ETB' | 'USD')} + onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')} data={[ { value: 'USD', label: 'USD' }, { value: 'ETB', label: 'Birr' }, + { value: 'DJF', label: 'DJF' }, ]} disabled={Boolean(activeInvoice)} /> diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts index d5fca3c6e..b7c26c152 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; const QUERY_KEY = ["exchangeSettings"]; +/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */ export const useExchangeSettingsQuery = () => useQuery({ queryKey: QUERY_KEY, - queryFn: () => exchangeSettingsService.get(), + queryFn: () => exchangeSettingsService.list(), // Feed health is only interesting while it is being looked at. staleTime: 30_000, refetchOnWindowFocus: true, @@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => { const { handleError } = useErrorHandler(t); return useMutation({ - mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), + mutationFn: ({ currency, rate }: { currency: string; rate: number }) => + exchangeSettingsService.setFallbackRate(currency, rate), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); toast.success( diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts index b49f33376..cbb5f5996 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts @@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => { return useMutation({ mutationFn: ( - patch: Partial>, + patch: Partial< + Pick + >, ) => manualPaymentSettingsService.update(patch), onSuccess: (data) => { queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data); diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 6ed13636a..a6db563cd 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -221,7 +221,7 @@ export function useOnTimeDispatch() { } /** Live per-item fee accrual (storage/demurrage) with alerts. */ -export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { +export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') { return useQuery({ queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), @@ -598,7 +598,7 @@ export const useUpdateFeeRule = () => export const useDeleteFeeRule = () => useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); -export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') { +export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' | 'DJF' = 'USD') { return useQuery({ queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency], queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data), @@ -649,7 +649,7 @@ export function useGenerateInvoice() { }: { inventoryId: string; confirmZero?: boolean; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: 'ETB' | 'USD' | 'DJF'; }) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data), onSuccess, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index a75e242e1..579c8f290 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { formatDateTime, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { BookingDetail } from "@/types/booking"; import { @@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() { const kpis: KpiItem[] = [ { label: "Total value", - value: formatMoney(amount, booking.paymentCurrency, 2), + value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)), hint: booking.paymentStatus, icon: Wallet, color: "edr-green", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx index 1a18d4cf0..7d1685328 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth"; import { PageContainer, PageHeader } from "@/components/page"; import { toDayString } from "@/hooks/useListControls"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { DataTable, @@ -166,7 +167,7 @@ export default function WagonCancellationsPage() { header: () => Fee, cell: ({ row }) => ( - {formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)} + {formatMoney(row.original.feeAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))} ), }, @@ -175,7 +176,7 @@ export default function WagonCancellationsPage() { header: () => Credit, cell: ({ row }) => ( - {formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)} + {formatMoney(row.original.creditAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))} ), }, @@ -347,7 +348,7 @@ export default function WagonCancellationsPage() { {voiding.booking?.reference ?? voiding.bookingId} ·{" "} {voiding.wagonsCancelled} wagon(s) · fee{" "} - {formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)} + {formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))} The pending fee is dropped and the wagons stay on the booking. diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index 455a6473e..b62d405fe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -82,6 +82,7 @@ const CONTRACT_KIND_OPTIONS = [ const CURRENCY_OPTIONS = [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, + { value: "DJF", label: "DJF" }, ]; /** value = `${sortBy}:${sortOrder}` for the sort Select. */ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx index bcfadb698..c0d577d89 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => { + diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 2b319de30..de8d46ce3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [ options: [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, + { value: "DJF", label: "DJF" }, ], }, { @@ -235,8 +236,23 @@ export default function InvoicesPanel() { const { data: exchangeSettings } = useExchangeSettingsQuery(); const etbCollected = summary?.ETB ?? 0; const usdCollected = summary?.USD ?? 0; - const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate; - const etbFromUsd = rate ? usdCollected * rate : null; + const djfCollected = summary?.DJF ?? 0; + const rateFor = (currency: string) => { + const setting = exchangeSettings?.find((s) => s.currency === currency); + return setting?.feed?.rate ?? setting?.fallbackRate ?? null; + }; + const usdRate = rateFor("USD"); + const djfRate = rateFor("DJF"); + const etbFromUsd = usdRate ? usdCollected * usdRate : null; + const etbFromDjf = djfRate ? djfCollected * djfRate : null; + const totalEtb = etbCollected + (etbFromUsd ?? 0) + (etbFromDjf ?? 0); + const totalHint = [ + "ETB", + etbFromUsd !== null ? "USD" : null, + etbFromDjf !== null ? "DJF" : null, + ] + .filter(Boolean) + .join(" + "); const columns: ColumnDef[] = useMemo( () => [ @@ -356,8 +372,8 @@ export default function InvoicesPanel() { items={[ { label: "Total collected", - hint: etbFromUsd !== null ? "ETB + USD" : "ETB only", - value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"), + hint: totalHint, + value: formatMoney(totalEtb, "ETB"), icon: CircleDollarSign, color: "edr-green", }, @@ -373,6 +389,12 @@ export default function InvoicesPanel() { icon: Landmark, color: "violet", }, + { + label: "Collected in DJF", + value: formatMoney(djfCollected, "DJF"), + icon: Landmark, + color: "orange", + }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index 8b3e58a5f..eeceae91b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -274,7 +274,7 @@ function ConfirmCell({ export default function UsdPaymentsPanel({ currency, }: { - currency: "USD" | "ETB"; + currency: "USD" | "ETB" | "DJF"; }) { const navigate = useNavigate(); // Namespaced: the ETB and USD tabs share this panel and live on the same URL diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 2c30fb11f..868137ba3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -27,6 +27,7 @@ import { useQuery } from "@tanstack/react-query"; import { KpiStrip } from "@/components/page"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { @@ -149,7 +150,7 @@ export default function PaymentsPanel() { header: () => Amount, cell: ({ row }) => ( - {formatMoney(row.original.amount, row.original.currency, 2)} + {formatMoney(row.original.amount, row.original.currency, currencyDecimals(row.original.currency))} ), }, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 62fecd600..0ec64f0bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -450,6 +450,7 @@ export const rateUnitOptions = ( const CURRENCIES = [ { label: "ETB (Birr)", value: "ETB" }, { label: "USD", value: "USD" }, + { label: "DJF", value: "DJF" }, ]; const PRIORITY_CONFIG_TYPES = [ diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx index da01b51ce..beee6c2a3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx @@ -14,7 +14,10 @@ import { useExchangeSettingsQuery, useSetExchangeFallbackRate, } from "@/hooks/useExchangeSettings"; -import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; +import type { + ExchangeRateSource, + ExchangeSetting, +} from "@/services/exchangeSettings.service"; import { formatDateTime } from "@/lib/format"; /** Feed health, phrased for an operator rather than a developer. */ @@ -38,40 +41,121 @@ function feedLabel(source: ExchangeRateSource | null): { const formatTime = (value: string | null) => value ? formatDateTime(value) : "never"; -/** - * USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. - * The live CBE rate always wins; every successful fetch overwrites the stored - * value, so it tracks the last known good rate on its own. Editing here is for - * a prolonged outage — the next successful CBE fetch replaces it. - */ -export default function ExchangeRateSettingsCard() { - const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); +/** One currency's fallback row — its own draft, its own save. */ +function ExchangeRateRow({ + setting, + disabled, +}: { + setting: ExchangeSetting; + disabled: boolean; +}) { const setRate = useSetExchangeFallbackRate(); const [draft, setDraft] = useState(""); - const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? ""); + const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? ""); const parsed = Number(value); - const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000; - const dirty = draft !== "" && parsed !== data?.fallbackRate; + const invalid = !Number.isFinite(parsed) || parsed <= 0; + const dirty = draft !== "" && parsed !== setting.fallbackRate; - const feed = feedLabel(data?.feed?.source ?? null); + const feed = feedLabel(setting.feed?.source ?? null); const handleSave = async () => { if (invalid) return; - await setRate.mutateAsync(parsed); + await setRate.mutateAsync({ currency: setting.currency, rate: parsed }); setDraft(""); }; + return ( +
+
+ {feed.live ? ( + + ) : ( + + )} +
+

+ {setting.currency} → ETB — {feed.text} +

+ {setting.feed?.rate != null && ( +

+ Rate in use: {setting.feed.rate} ETB per {setting.currency} +

+ )} +

+ Last successful update: {formatTime(setting.feed?.lastSuccessAt ?? null)} +

+ {setting.feed?.lastError && ( +

Last error: {setting.feed.lastError}

+ )} +
+
+ +
+ +
+ setDraft(e.target.value)} + /> + +
+ {invalid && draft !== "" && ( +

Enter a rate greater than 0.

+ )} +

+ {setting.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`} +

+
+
+ ); +} + +/** + * X→ETB fallback used when the CBE exchange-rate endpoint is unreachable for + * that currency — one row per foreign currency (USD, DJF). The live CBE rate + * always wins; every successful fetch overwrites the stored value, so it + * tracks the last known good rate on its own. Editing here is for a + * prolonged outage — the next successful CBE fetch replaces it. + */ +export default function ExchangeRateSettingsCard() { + const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); + return (
- Exchange rate (USD → ETB) + Exchange rates (→ ETB) - Rates come from the Commercial Bank of Ethiopia. The fallback - below is used only when CBE cannot be reached, and is refreshed - automatically after every successful update. + Rates come from the Commercial Bank of Ethiopia. Each fallback + below is used only when CBE cannot be reached for that currency, + and is refreshed automatically after every successful update.
-
- {invalid && draft !== "" && ( -

- Enter a rate between 1 and 10,000. -

- )} -

- {data?.fallbackSource === "MANUAL" - ? "Set manually. The next successful CBE update will replace it." - : `Synced automatically from CBE (${formatTime( - data?.lastSyncedAt ?? null, - )}).`} -

- + {(data ?? []).map((setting) => ( + + ))}
); diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx index 3fe5c1e1e..d7386259a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx @@ -17,11 +17,11 @@ import { useUpdateManualPaymentSettings, } from "@/hooks/useManualPaymentSettings"; -type Currency = "ETB" | "USD"; +type Currency = "ETB" | "USD" | "DJF"; const CURRENCIES: { code: Currency; - field: "etbEnabled" | "usdEnabled"; + field: "etbEnabled" | "usdEnabled" | "djfEnabled"; icon: typeof Banknote; title: string; description: string; @@ -42,6 +42,14 @@ const CURRENCIES: { description: "USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.", }, + { + code: "DJF", + field: "djfEnabled", + icon: Landmark, + title: "Djibouti Franc (DJF) invoices", + description: + "DJF invoices can be paid online (Waafi / CAC Bank) or by bank transfer. Switch this off if Finance should stop accepting DJF payments by hand.", + }, ]; /** @@ -60,7 +68,9 @@ export default function ManualPaymentSettingsCard() { const { data, isLoading } = useManualPaymentSettingsQuery(); const update = useUpdateManualPaymentSettings(); - const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled); + const noneEnabled = Boolean( + data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled, + ); return ( @@ -79,7 +89,7 @@ export default function ManualPaymentSettingsCard() {

- Both currencies are off — the Manual Payments list is empty and + Every currency is off — the Manual Payments list is empty and Finance cannot settle any invoice by hand.

diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx index 3c2b487df..41b4c8679 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -16,7 +16,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { DataTable, type ColumnDef, currencyDecimals } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; @@ -45,7 +45,7 @@ const STATUS_META: Record amount == null ? "—" - : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim(); + : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: currencyDecimals(currency) })} ${currency ?? ""}`.trim(); /** * The queue for customer-initiated empty container returns: a booking sold diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx index a074006d8..05e89a1f9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx @@ -1,4 +1,5 @@ import { Fragment, useMemo, useState } from "react"; +import { currencyDecimals } from "@edr/ui-common"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActionIcon, @@ -78,7 +79,7 @@ const TRUCK_COLUMNS = [ ] as const; const money = (amount: number, currency: string) => - `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`; + `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: currencyDecimals(currency) })} ${currency === "ETB" ? "ETB" : currency}`; export interface BookingGroup { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 974755a71..1112778dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -17,7 +17,7 @@ import { } from '@mantine/core'; import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { DataTable, type ColumnDef, currencyDecimals } from '@edr/ui-common'; import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters'; import { PageContainer, PageHeader } from '@/components/page'; @@ -50,7 +50,7 @@ const STATUS_COLOR: Record = { CANCELLED: 'gray', }; -const fmt = (n: number, c: string) => formatMoney(n, c, 2); +const fmt = (n: number, c: string) => formatMoney(n, c, currencyDecimals(c)); const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); const INVOICE_FILTER_DEFS: FilterDef[] = [ @@ -204,7 +204,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); useEffect(() => { - setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'); + // WAAFI settles USD and DJF; TELEBIRR is ETB-only. + setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR'); setPayerAccount(''); }, [inv?.id, inv?.currency]); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 8c432f92a..02ced15fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -69,6 +69,7 @@ const TRADE = [ const CURRENCIES = [ { value: 'USD', label: 'USD - Dollar' }, { value: 'ETB', label: 'ETB - Birr' }, + { value: 'DJF', label: 'DJF - Djibouti Franc' }, ]; const clean = (s: string) => s.trim() || undefined; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index f435651ed..4ce553842 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1533,7 +1533,7 @@ export const api = { ), feePreview: endpoint< - { inventoryId: string; billingCurrency?: "ETB" | "USD" }, + { inventoryId: string; billingCurrency?: "ETB" | "USD" | "DJF" }, FeePreview[] >( "warehouse-inventory", @@ -1885,7 +1885,7 @@ export const api = { { inventoryId: string; confirmZero?: boolean; - billingCurrency?: "ETB" | "USD"; + billingCurrency?: "ETB" | "USD" | "DJF"; }, WarehouseFeeInvoice >( diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts index 37b8dd254..d0591b775 100644 --- a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts @@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE; */ export type ExchangeRateSource = "live" | "stored"; -/** Health of the CBE exchange-rate feed. */ +/** Health of the CBE exchange-rate feed for one currency. */ export interface ExchangeFeedStatus { rate: number | null; source: ExchangeRateSource | null; @@ -19,25 +19,31 @@ export interface ExchangeFeedStatus { lastError: string | null; } -export interface ExchangeSettings { - fallbackRate: number; - /** `AUTO` when synced from CBE, `MANUAL` when set here. */ - fallbackSource: "AUTO" | "MANUAL"; +/** One currency's X→ETB fallback settings — the API returns one per foreign currency. */ +export interface ExchangeSetting { + currency: string; + fallbackRate: number | null; + /** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */ + fallbackSource: "AUTO" | "MANUAL" | null; lastSyncedAt: string | null; updatedById: string | null; feed?: ExchangeFeedStatus; } export const exchangeSettingsService = { - get: async (): Promise => { - const response = await client.get>(BASE); + list: async (): Promise => { + const response = await client.get>(BASE); return unwrap(response.data); }, - setFallbackRate: async (fallbackRate: number): Promise => { - const response = await client.patch>(BASE, { - fallbackRate, - }); + setFallbackRate: async ( + currency: string, + fallbackRate: number, + ): Promise => { + const response = await client.patch>( + `${BASE}/${currency}`, + { fallbackRate }, + ); return unwrap(response.data); }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts index 6710730cb..ffe5281ec 100644 --- a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts @@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE; export interface ManualPaymentSettings { etbEnabled: boolean; usdEnabled: boolean; + djfEnabled: boolean; updatedById: string | null; updatedAt?: string; } @@ -25,7 +26,9 @@ export const manualPaymentSettingsService = { /** Partial: an omitted currency keeps its current setting. */ update: async ( - patch: Partial>, + patch: Partial< + Pick + >, ): Promise => { const response = await client.patch>( BASE, diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 98a8d59e4..18368110f 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -558,11 +558,11 @@ export const warehouseService = { updateFeeRule: (id: string, payload: Partial) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload), deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)), - feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') => + feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD' | 'DJF') => apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { params: cleanParams({ billingCurrency }), }), - accrualDashboard: (billingCurrency?: 'ETB' | 'USD') => + accrualDashboard: (billingCurrency?: 'ETB' | 'USD' | 'DJF') => apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { params: cleanParams({ billingCurrency }), }), @@ -592,7 +592,7 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)), invoicesForBooking: (bookingId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)), - generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') => + generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD' | 'DJF') => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero, billingCurrency, diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 1d3eb7588..57be90f90 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -420,7 +420,7 @@ export interface CustomerBooking { originLabel: string; destinationLabel: string; totalAmount: number; - currency: "ETB" | "USD"; + currency: "ETB" | "USD" | "DJF"; scheduledDate?: string | null; createdAt: string; } @@ -469,7 +469,7 @@ export interface CustomerPayment { /** Booking reference the payment settles. */ bookingReference: string; amount: number; - currency: "ETB" | "USD"; + currency: "ETB" | "USD" | "DJF"; method: CustomerPaymentMethod; status: CustomerPaymentStatus; paidAt?: string | null; diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 218e478fa..0807894c1 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -77,7 +77,7 @@ export interface InvoiceListFilter { /** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */ paymentMethods?: string; search?: string; - currency?: "USD" | "ETB"; + currency?: "USD" | "ETB" | "DJF"; /** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */ issuedFrom?: string; issuedTo?: string; diff --git a/apps/edr-freight-web/portal/src/lib/currency.ts b/apps/edr-freight-web/portal/src/lib/currency.ts index d41b4edda..270882fc8 100644 --- a/apps/edr-freight-web/portal/src/lib/currency.ts +++ b/apps/edr-freight-web/portal/src/lib/currency.ts @@ -1,23 +1,8 @@ -/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */ -export type Currency = string; - -const SYMBOLS: Record = { - USD: "$", - ETB: "Br", - DJF: "DJF", -}; - /** - * Format a money amount with its currency symbol, e.g. `Br 12,500.00`. - * Unknown currency codes fall back to printing the raw code. + * Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). + * Re-exports the shared `@edr/ui-common` currency module so every currency + * gets the same symbol, decimals rule and formatting across both freight web + * apps — see that module for the source of truth. */ -export function formatCurrency( - amount: number, - currency: Currency = "ETB", -): string { - const symbol = SYMBOLS[currency] ?? currency; - return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; -} +export type { SupportedCurrency as Currency } from "@edr/ui-common"; +export { formatCurrency, currencySymbol, currencyDecimals, CURRENCY_CODES } from "@edr/ui-common"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts index b33017976..0360c579b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts @@ -1,6 +1,6 @@ import type { Freight } from "@edr/types"; -import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; +import { bookingCanPayOnline } from "@/pages/bookings/payments/offline-payment"; /** A pending customer action surfaced on the home "needs attention" card. */ export interface ActionItem { @@ -64,7 +64,10 @@ export function deriveActionItems( ? b.status === "FULLY_EXECUTED" : b.status === "SELECTED_FOR_BATCH"); if (canPay) { - const offlinePay = isUsdOfflineBooking(b); + // Online-only description unless the booking's currency has no online + // rail at all (USD) — DJF supports both, so it reads as a normal + // "payment due" like ETB rather than bank-transfer-only. + const offlinePay = !bookingCanPayOnline(b); items.push({ id: `pay-${b.id}`, kind: "pay", diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index b42a3cc8e..e3311d2f2 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -32,7 +32,7 @@ import { invoicesService } from "@/services/invoices.service"; import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; -import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment"; +import { canPayOffline, canPayOnline } from "@/pages/bookings/payments/offline-payment"; import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; @@ -232,7 +232,7 @@ export default function InvoiceDetailPage() { Receipt )} - {payable && !isUsdCurrency(invoice.currency) && ( + {payable && canPayOnline(invoice.currency) && (