mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 13:38:20 +00:00
Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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)." })
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
|
||||
import { CARGO_CONDITIONS, CargoCondition, FREIGHT_TYPES, FreightType } from './entities/booking.entity';
|
||||
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
|
||||
|
||||
/** Normalize and validate booking freight shape (used on create and after update merge). */
|
||||
@@ -12,10 +12,25 @@ export function assertFreightShape(input: BookingFreightShapeInput): void {
|
||||
}
|
||||
//
|
||||
|
||||
const condition = input.cargoCondition ?? 'LADEN';
|
||||
if (!CARGO_CONDITIONS.includes(condition as CargoCondition)) {
|
||||
throw new BadRequestException(
|
||||
`cargoCondition must be one of: ${CARGO_CONDITIONS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const containers = input.containers ?? [];
|
||||
const hasContainers = containers.length > 0;
|
||||
const hasCargoType = Boolean(input.cargoTypeId);
|
||||
|
||||
// Empty means bare equipment: there is no commodity to name, and bulk has no
|
||||
// equipment of its own to move, so EMPTY only ever rides CONTAINER freight.
|
||||
if (condition === 'EMPTY' && input.freightType !== 'CONTAINER') {
|
||||
throw new BadRequestException(
|
||||
'An empty booking must be CONTAINER freight — bulk carries no equipment',
|
||||
);
|
||||
}
|
||||
|
||||
if (input.freightType === 'BULK') {
|
||||
if (hasContainers) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -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,
|
||||
@@ -772,3 +779,124 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
|
||||
expect(line.amount).toBe(3 * 1690);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty container import is bare equipment moved as freight in its own right.
|
||||
* It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT
|
||||
* rate for the same lane and box — the two are separate tariffs, and
|
||||
* UQ_rates_pattern only lets both exist because the rateType differs.
|
||||
*/
|
||||
describe('BookingPricingService — empty container import', () => {
|
||||
const DJIBOUTI = 'yard-djibouti';
|
||||
const CT40 = 'ct-40ft';
|
||||
|
||||
const ladenImport40: Rate = {
|
||||
id: 'rate-container-import-40',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
currency: 'USD',
|
||||
rateValue: 900,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: CT40,
|
||||
originYardId: DJIBOUTI,
|
||||
destinationYardId: MOJO,
|
||||
} as Rate;
|
||||
|
||||
const emptyImport40: Rate = {
|
||||
id: 'rate-empty-container-import-40',
|
||||
rateType: 'EMPTY_CONTAINER_IMPORT',
|
||||
currency: 'USD',
|
||||
rateValue: 250,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: CT40,
|
||||
originYardId: DJIBOUTI,
|
||||
destinationYardId: MOJO,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
|
||||
const priceLines = (booking: Booking) =>
|
||||
(
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> },
|
||||
) => Promise<{
|
||||
lineItems: Array<{ code: string; amount: number; description: string }>;
|
||||
blocked: string[];
|
||||
}>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }],
|
||||
});
|
||||
|
||||
const bookingWith = (cargoCondition: string) =>
|
||||
({
|
||||
id: 'b-empty-1',
|
||||
freightType: 'CONTAINER',
|
||||
cargoCondition,
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
// Bare equipment declares no VGM — the service zeroes it at create.
|
||||
cargoTotalWeightVgm: 0,
|
||||
originYardId: DJIBOUTI,
|
||||
destinationYardId: MOJO,
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
const exchangeService = {
|
||||
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }),
|
||||
};
|
||||
service = new BookingPricingService(
|
||||
{ calculateWagonCount: jest.fn().mockResolvedValue(4) } as never,
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never,
|
||||
exchangeService as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices an empty booking off the empty tariff, not the laden one', async () => {
|
||||
const result = await priceLines(bookingWith('EMPTY'));
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT');
|
||||
expect(result.lineItems[0].amount).toBe(250 * 4);
|
||||
expect(result.lineItems[0].description).toContain('empty');
|
||||
});
|
||||
|
||||
it('leaves laden bookings on the laden tariff', async () => {
|
||||
const result = await priceLines(bookingWith('LADEN'));
|
||||
|
||||
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
|
||||
expect(result.lineItems[0].amount).toBe(900 * 4);
|
||||
});
|
||||
|
||||
it('treats a booking with no condition set as laden', async () => {
|
||||
const booking = bookingWith('LADEN');
|
||||
delete (booking as unknown as Record<string, unknown>).cargoCondition;
|
||||
|
||||
const result = await priceLines(booking);
|
||||
|
||||
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
|
||||
});
|
||||
|
||||
it('hard-blocks an empty booking on a lane with no empty rate configured', async () => {
|
||||
(
|
||||
service as unknown as { ratesService: { findLiveRates: jest.Mock } }
|
||||
).ratesService.findLiveRates.mockResolvedValue([ladenImport40]);
|
||||
|
||||
const result = await priceLines(bookingWith('EMPTY'));
|
||||
|
||||
// Never silently fall through to the laden rate — that would bill an empty
|
||||
// repositioning move at 900/box instead of 250.
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -248,8 +249,10 @@ export class BookingPricingService {
|
||||
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||||
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||||
// with nothing configured hard-blocks — clearance never ships for free.
|
||||
// An empty box carries no declaration and no duty, so there is no clearance
|
||||
// to sell even if a customs-bundled service type was somehow selected.
|
||||
const clearanceBlocked: string[] = [];
|
||||
if (booking.customsClearingEnabled) {
|
||||
if (booking.customsClearingEnabled && booking.cargoCondition !== 'EMPTY') {
|
||||
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||||
for (const line of clearance.lineItems) {
|
||||
lineItems.push(line);
|
||||
@@ -570,12 +573,21 @@ 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';
|
||||
// Bare equipment prices off its own tariff. It has to be a distinct
|
||||
// rateType, not a cheaper CONTAINER_IMPORT row: UQ_rates_pattern keys on
|
||||
// rate_type without applies_to, so an empty 40ft rate on a lane would
|
||||
// collide with the laden 40ft rate for that same lane.
|
||||
const isEmpty = booking.cargoCondition === 'EMPTY';
|
||||
|
||||
const rateType =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
const rateType = isEmpty
|
||||
? booking.tradeDirection === 'EXPORT'
|
||||
? 'EMPTY_CONTAINER_EXPORT'
|
||||
: 'EMPTY_CONTAINER_IMPORT'
|
||||
: booking.tradeDirection === 'IMPORT'
|
||||
? isBulk
|
||||
? 'BULK_IMPORT'
|
||||
: 'CONTAINER_IMPORT'
|
||||
@@ -608,7 +620,7 @@ export class BookingPricingService {
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (!rate && !frozen) {
|
||||
@@ -649,7 +661,7 @@ export class BookingPricingService {
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `${label} rail freight`,
|
||||
description: isEmpty ? `${label} empty rail freight` : `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit: rateUnit,
|
||||
@@ -698,7 +710,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 +783,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 +837,7 @@ export class BookingPricingService {
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -1021,13 +1034,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<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
@@ -1035,15 +1053,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 +1075,7 @@ export class BookingPricingService {
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
@@ -1071,7 +1085,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 +1107,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 +1146,7 @@ export class BookingPricingService {
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has(`${customsType}_20FT`) ||
|
||||
frozenRates?.has(`${customsType}_40FT`);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, fx);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
@@ -1161,7 +1175,7 @@ export class BookingPricingService {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
|
||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, fx)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
@@ -1196,7 +1210,7 @@ export class BookingPricingService {
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||
// commodity-less rate (legacy) is the catch-all fallback.
|
||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, fx);
|
||||
const live =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find(
|
||||
|
||||
@@ -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<PricedFee> {
|
||||
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 {
|
||||
|
||||
@@ -1106,11 +1106,13 @@ ${footer}
|
||||
const containers = await Promise.all(
|
||||
containerLines.map(async (c) => {
|
||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
||||
// Optional on the DTO — an empty booking states no VGM at all.
|
||||
const vgmPerUnitTons = Number(c.vgmPerUnitTons ?? 0);
|
||||
const totalVgmTons = c.quantity * vgmPerUnitTons;
|
||||
return {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
|
||||
@@ -1375,13 +1377,25 @@ ${footer}
|
||||
}
|
||||
}
|
||||
|
||||
const containers = dto.containers ?? [];
|
||||
const cargoCondition = dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN';
|
||||
const isEmpty = cargoCondition === 'EMPTY';
|
||||
assertFreightShape({
|
||||
freightType: dto.freightType,
|
||||
cargoCondition,
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
containers,
|
||||
containers: dto.containers ?? [],
|
||||
});
|
||||
|
||||
// Bare equipment declares no VGM. Zero the lines HERE, before the rule
|
||||
// engine sees them, so weight-limit and overweight evaluation, the wagon
|
||||
// estimate, the persisted rows and every tonnage aggregate downstream all
|
||||
// read the same figure — a stray VGM on an empty line would otherwise price
|
||||
// an overweight surcharge on a box with nothing in it.
|
||||
const containers = (dto.containers ?? []).map((c) => ({
|
||||
...c,
|
||||
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
|
||||
}));
|
||||
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
@@ -1506,10 +1520,11 @@ ${footer}
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoCondition,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||
cargoTotalWeightVgm: isEmpty ? 0 : dto.cargoTotalWeightVgm,
|
||||
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
|
||||
bulkTotalWeightTons:
|
||||
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
|
||||
@@ -1647,6 +1662,11 @@ ${footer}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||
// A draft may be switched between laden and empty; an untouched draft keeps
|
||||
// whatever it was created as.
|
||||
const cargoCondition =
|
||||
(dto.cargoCondition ?? existing.cargoCondition) === 'EMPTY' ? 'EMPTY' : 'LADEN';
|
||||
const isEmpty = cargoCondition === 'EMPTY';
|
||||
let containers =
|
||||
dto.containers ??
|
||||
(existing.bookingContainers ?? [])
|
||||
@@ -1672,7 +1692,14 @@ ${footer}
|
||||
}
|
||||
}
|
||||
|
||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||
// Same normalisation as create: zero the VGM of an empty booking before the
|
||||
// rule engine, the wagon estimate or the persisted rows ever read it.
|
||||
containers = containers.map((c) => ({
|
||||
...c,
|
||||
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
|
||||
}));
|
||||
|
||||
assertFreightShape({ freightType, cargoCondition, cargoTypeId, containers });
|
||||
|
||||
const originYardId = dto.originYardId ?? existing.originYardId;
|
||||
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
||||
@@ -1719,6 +1746,9 @@ ${footer}
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoCondition,
|
||||
// Bare equipment declares no VGM, whichever way the draft was edited.
|
||||
cargoTotalWeightVgm: isEmpty ? 0 : cargoAmount,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
|
||||
bulkTotalWeightTons:
|
||||
@@ -1825,10 +1855,12 @@ ${footer}
|
||||
await this.bookingsRepository.deleteContainers(id);
|
||||
await this.bookingsRepository.createContainers(
|
||||
id,
|
||||
// Index-aligned with ruleResult, which evaluated these same lines.
|
||||
dto.containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
// Bare equipment declares no VGM — same normalisation the rule engine saw.
|
||||
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
BOOKING_TYPES,
|
||||
CARGO_CONDITIONS,
|
||||
FREIGHT_TYPES,
|
||||
} from '../entities/booking.entity';
|
||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||
|
||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||
@@ -47,11 +52,20 @@ export class CreateBookingContainerDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
|
||||
/**
|
||||
* Omitted on an empty booking — bare equipment has no verified gross mass to
|
||||
* declare, and the service zeroes the line rather than trusting a stray value.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'VGM per container in tons. Omit for an EMPTY booking',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons!: number;
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
vgmPerUnitTons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are hazardous (0..quantity)',
|
||||
@@ -312,6 +326,20 @@ export class CreateBookingDto {
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType!: string;
|
||||
|
||||
/**
|
||||
* LADEN (default) or EMPTY. EMPTY is container freight carrying nothing —
|
||||
* the box itself is the shipment, priced per size and lane off an
|
||||
* EMPTY_CONTAINER_IMPORT rate.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: CARGO_CONDITIONS,
|
||||
default: 'LADEN',
|
||||
description: 'EMPTY moves bare equipment; requires CONTAINER freight',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...CARGO_CONDITIONS])
|
||||
cargoCondition?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Required for BULK; must be omitted for CONTAINER',
|
||||
@@ -330,10 +358,14 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
shippingLineId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
|
||||
@ApiProperty({
|
||||
description: 'Total cargo weight VGM in tons. Omit for an EMPTY booking',
|
||||
minimum: 0,
|
||||
})
|
||||
@ValidateIf((o) => o.cargoCondition !== 'EMPTY')
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
cargoTotalWeightVgm!: number;
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
|
||||
|
||||
export interface BookingFreightShapeInput {
|
||||
freightType?: string;
|
||||
/** LADEN (default) or EMPTY — see CARGO_CONDITIONS on the Booking entity. */
|
||||
cargoCondition?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
containers?: Array<{ containerTypeId?: string }> | null;
|
||||
}
|
||||
@@ -20,6 +22,13 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bulk carries no equipment of its own, so an empty booking is always
|
||||
// container freight. Rejected here as well as in assertFreightShape so the
|
||||
// 400 names the field instead of surfacing from the service layer.
|
||||
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const containers = dto.containers ?? [];
|
||||
const hasContainers = containers.length > 0;
|
||||
const hasCargoType =
|
||||
@@ -49,6 +58,9 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const dto = args.object as BookingFreightShapeInput;
|
||||
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
|
||||
return 'An empty booking must be CONTAINER freight — bulk carries no equipment';
|
||||
}
|
||||
if (dto.freightType === 'BULK') {
|
||||
return 'BULK freight requires cargoTypeId and must not include container lines';
|
||||
}
|
||||
|
||||
@@ -83,6 +83,20 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Whether the booking moves cargo or bare equipment. EMPTY is container
|
||||
* freight with nothing inside: the box IS the shipment, priced per size and
|
||||
* lane off an EMPTY_CONTAINER_IMPORT rate.
|
||||
*
|
||||
* This is deliberately NOT a third `freightType`. An empty booking is still
|
||||
* CONTAINER freight everywhere it matters physically — wagon footprint, yard
|
||||
* and warehouse allocation, train scheduling, marshalling, gate passes — and
|
||||
* `freightType` is read in ~880 places whose else-arm means "container". Only
|
||||
* pricing, documents, customs and the contract template branch on condition.
|
||||
*/
|
||||
export const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
|
||||
export type CargoCondition = (typeof CARGO_CONDITIONS)[number];
|
||||
|
||||
export const SCHEDULING_STATUSES = [
|
||||
SchedulingStatus.NotScheduled,
|
||||
SchedulingStatus.Holding,
|
||||
@@ -388,6 +402,13 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
||||
freightType!: string;
|
||||
|
||||
/**
|
||||
* LADEN (the default, and every pre-existing row) or EMPTY. Only ever EMPTY
|
||||
* on CONTAINER freight — bulk has no equipment to move on its own.
|
||||
*/
|
||||
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
|
||||
cargoCondition!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
|
||||
@@ -53,24 +53,60 @@ describe('contractTemplateCodeFor', () => {
|
||||
it('only ever resolves to a code that exists', () => {
|
||||
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
|
||||
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
|
||||
const conditions = ['LADEN', 'EMPTY', null, undefined];
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
for (const e of [true, false, undefined]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e),
|
||||
);
|
||||
for (const cond of conditions) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e, cond),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Empty equipment is a carriage agreement, not a cargo contract: no cargo
|
||||
// liability, no VGM declaration, no commercial documents, no customs leg.
|
||||
it('gives empty container import its own customs-free paper', () => {
|
||||
for (const customs of [true, false]) {
|
||||
for (const ethiopian of [true, false, undefined]) {
|
||||
expect(
|
||||
contractTemplateCodeFor('IMPORT', 'CONTAINER', customs, ethiopian, 'EMPTY'),
|
||||
).toBe('IMPORT_EMPTY_CONTAINER');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves laden contracts on the laden codes', () => {
|
||||
expect(
|
||||
contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false, 'LADEN'),
|
||||
).toBe('IMPORT_CONTAINER_NO_CUSTOMS');
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
// Empty rates and empty bookings are import-only, so a stray EMPTY on any
|
||||
// other direction must fall through rather than resolve a template that
|
||||
// describes a Djibouti-to-Ethiopia movement.
|
||||
it('ignores the empty condition outside import', () => {
|
||||
expect(
|
||||
contractTemplateCodeFor('EXPORT', 'CONTAINER', false, false, 'EMPTY'),
|
||||
).toBe('EXPORT_CONTAINER_NO_CUSTOMS');
|
||||
expect(
|
||||
contractTemplateCodeFor('DOMESTIC', 'CONTAINER', false, false, 'EMPTY'),
|
||||
).toBe('INTERCITY_CONTAINER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the fourteen declared codes, once each', () => {
|
||||
it('seeds exactly the fifteen declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(14);
|
||||
expect(seeded).toHaveLength(15);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
// Carriage of the equipment itself — no cargo, no clearing, so it previews
|
||||
// against the transport-only scope like every other non-customs code.
|
||||
IMPORT_EMPTY_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -216,6 +219,7 @@ export class ContractTemplatesService {
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
cargoCondition?: string | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
@@ -235,6 +239,7 @@ export class ContractTemplatesService {
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
ethiopianCustomsOnly,
|
||||
cargoCondition,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
|
||||
@@ -41,6 +41,14 @@ export const CONTRACT_TEMPLATE_CODES = [
|
||||
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
/**
|
||||
* Empty container import — bare equipment railed north from Djibouti. No
|
||||
* customs split: an empty box carries no declaration to clear, the same
|
||||
* reason intercity has a single unsuffixed code. Import-only, matching the
|
||||
* rate rule (southbound empties are served by the WITH_RETURN surcharge and
|
||||
* empty_return_requests instead).
|
||||
*/
|
||||
"IMPORT_EMPTY_CONTAINER",
|
||||
] as const;
|
||||
|
||||
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
|
||||
@@ -74,7 +82,14 @@ export function contractTemplateCodeFor(
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
cargoCondition?: string | null,
|
||||
): ContractTemplateCode {
|
||||
// Empty equipment is its own paper: a straight carriage agreement with no
|
||||
// cargo liability, no VGM declaration and no customs leg. Import-only, so
|
||||
// anything else falls through to the laden codes below.
|
||||
if (cargoCondition === "EMPTY" && tradeDirection === "IMPORT") {
|
||||
return "IMPORT_EMPTY_CONTAINER";
|
||||
}
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
? "IMPORT"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -430,6 +430,8 @@ export class ContractTransitionService {
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||
// An empty-equipment contract resolves to the carriage-only paper.
|
||||
contract.cargoCondition,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -433,6 +433,7 @@ export class ContractsService {
|
||||
renewalOfId: dto.renewalOfId ?? null,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoCondition: dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN',
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||||
// booking (or on the shipment request when GL books for the customer), so
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||
@@ -154,6 +155,15 @@ export class CreateContractDto {
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType!: string;
|
||||
|
||||
/**
|
||||
* LADEN (default) or EMPTY. EMPTY commits to moving bare equipment and is
|
||||
* container freight only.
|
||||
*/
|
||||
@ApiPropertyOptional({ enum: CARGO_CONDITIONS, default: 'LADEN' })
|
||||
@IsOptional()
|
||||
@IsIn([...CARGO_CONDITIONS])
|
||||
cargoCondition?: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||
@IsUUID()
|
||||
serviceTypeId!: string;
|
||||
|
||||
@@ -150,6 +150,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
freightType!: string;
|
||||
|
||||
/**
|
||||
* LADEN (the default, and every pre-existing row) or EMPTY. An EMPTY contract
|
||||
* commits to moving bare equipment and resolves the IMPORT_EMPTY_CONTAINER
|
||||
* template — a straight carriage agreement with no cargo or customs articles.
|
||||
*/
|
||||
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
|
||||
cargoCondition!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
||||
const frozenByCode = (
|
||||
snap: ContractRateSnapshot | null,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
): ContractRateSnapshot | null =>
|
||||
(
|
||||
BookingPricingService.prototype as unknown as {
|
||||
@@ -28,14 +28,14 @@ const frozenByCode = (
|
||||
m: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
fx: Record<string, number>,
|
||||
) => ContractRateSnapshot | null;
|
||||
}
|
||||
).frozenRateByCode(
|
||||
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||
'CONTAINER_20FT',
|
||||
bookingCurrency,
|
||||
usdToEtb,
|
||||
fx,
|
||||
);
|
||||
|
||||
describe('per-shipment billing currency', () => {
|
||||
@@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => {
|
||||
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
||||
// The old behaviour returned null here, which silently re-priced the
|
||||
// booking at live rates and lost the agreed contract price.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000);
|
||||
});
|
||||
|
||||
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
||||
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
|
||||
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400);
|
||||
});
|
||||
|
||||
it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => {
|
||||
// 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040);
|
||||
});
|
||||
|
||||
it('passes a matching-currency snapshot through untouched', () => {
|
||||
const snap = snapshot('USD', 400);
|
||||
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
|
||||
expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap);
|
||||
});
|
||||
|
||||
it('refuses to price off an unusable exchange rate', () => {
|
||||
// Converting with 0 would zero the whole line.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to price off a currency the rate table has no entry for', () => {
|
||||
expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no snapshot', () => {
|
||||
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
||||
expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ExchangeOptions>("app.cbeExchange") ?? {}),
|
||||
loadFallbackRate: () => settings.loadFallbackRate(),
|
||||
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
|
||||
loadFallbackRate: (code) => settings.loadFallbackRate(code),
|
||||
saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<CurrencyCode, number> = {
|
||||
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,
|
||||
|
||||
@@ -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<Record<CurrencyCode, number>> = {
|
||||
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<string, ExchangeFeedStatus>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExchangeSetting)
|
||||
private readonly repository: Repository<ExchangeSetting>,
|
||||
) {}
|
||||
|
||||
/** 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<ExchangeSetting> {
|
||||
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<ExchangeSetting> {
|
||||
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<ExchangeSetting[]> {
|
||||
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<number | null> {
|
||||
async loadFallbackRate(code: CurrencyCode): Promise<number | null> {
|
||||
// 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<void> {
|
||||
async saveFallbackRate(code: CurrencyCode, rate: number): Promise<void> {
|
||||
// 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<ExchangeSetting> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
usdEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
djfEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ManualPaymentCurrency, "etbEnabled" | "usdEnabled" | "djfEnabled"> = {
|
||||
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<boolean> {
|
||||
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<ManualPaymentSetting> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* Metadata fields for `POST /publications`, sent alongside the file as
|
||||
* multipart/form-data — every field arrives as a string, so numeric/boolean
|
||||
* fields need an explicit `@Transform` (global `enableImplicitConversion` is
|
||||
* off, see main.ts).
|
||||
*/
|
||||
export class CreatePublicationDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === undefined || value === "true" || value === true)
|
||||
published?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
|
||||
import { CreatePublicationDto } from "./create-publication.dto";
|
||||
|
||||
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* One document in the freight portal's public library (/publications) — a
|
||||
* PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and
|
||||
* curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs
|
||||
* edited in place, this is a real table of many rows and each upload is a
|
||||
* whole new file — there is no version-history log here, a re-upload just
|
||||
* replaces the file columns (see `PublicationsService.replaceFile`).
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "publications" })
|
||||
@Index(["published", "sortOrder"])
|
||||
export class Publication extends BaseEntity {
|
||||
@Column({ name: "title", type: "varchar", length: 200 })
|
||||
title!: string;
|
||||
|
||||
@Column({ name: "description", type: "text", nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: "category", type: "varchar", length: 60, nullable: true })
|
||||
category?: string | null;
|
||||
|
||||
/** MinIO object key. Never a signed URL — those expire; sign on read instead. */
|
||||
@Column({ name: "file_key", type: "varchar", length: 512 })
|
||||
fileKey!: string;
|
||||
|
||||
/** Original filename, used for the download's Content-Disposition. */
|
||||
@Column({ name: "file_name", type: "varchar", length: 255 })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: "file_mime_type", type: "varchar", length: 120 })
|
||||
fileMimeType!: string;
|
||||
|
||||
@Column({ name: "file_size_bytes", type: "bigint" })
|
||||
fileSizeBytes!: number;
|
||||
|
||||
/** Manual ordering in the backoffice list and the public grid. */
|
||||
@Column({ name: "sort_order", type: "integer", default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
/** Unpublish without deleting — hides it from the public list only. */
|
||||
@Column({ name: "published", type: "boolean", default: true })
|
||||
published!: boolean;
|
||||
|
||||
@Column({ name: "published_at", type: "timestamptz", nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Column({ name: "uploaded_by_id", type: "uuid", nullable: true })
|
||||
uploadedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PublicationsService } from "./publications.service";
|
||||
|
||||
/**
|
||||
* The portal's /publications page — a public library of PDFs, Markdown
|
||||
* write-ups and PowerPoint decks about the platform. No login required, same
|
||||
* as /help, /faq and the legal pages: prospects reach it before any account
|
||||
* exists.
|
||||
*/
|
||||
@ApiTags("publications")
|
||||
@Public()
|
||||
@Controller("publications")
|
||||
export class PublicPublicationsController {
|
||||
constructor(private readonly service: PublicationsService) {}
|
||||
|
||||
@Get()
|
||||
// Cheap to serve stale for a few minutes; every anonymous page view hits it.
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
@ApiOperation({ summary: "List published publications for the public library" })
|
||||
list() {
|
||||
return this.service.listPublic();
|
||||
}
|
||||
|
||||
@Get(":id/file")
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
required: false,
|
||||
description: "Set to 1/true to force a download instead of inline preview.",
|
||||
})
|
||||
@ApiOperation({ summary: "Stream a published publication's file" })
|
||||
async getFile(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.service.getPublishedFileStream(id);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
|
||||
res.setHeader("Content-Type", record.fileMimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`,
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=300");
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { documentUploadMulterOptions } from "../../common/document-upload.options";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||
import { PublicationsService } from "./publications.service";
|
||||
|
||||
const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||
const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||
|
||||
@ApiTags("publications")
|
||||
@ApiBearerAuth()
|
||||
@Controller("publications")
|
||||
export class PublicationsController {
|
||||
constructor(private readonly service: PublicationsService) {}
|
||||
|
||||
@Get("admin")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "List every publication, published or not" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload a new publication" })
|
||||
create(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CreatePublicationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.create(file, dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({ summary: "Update a publication's title, description, category, order or published state" })
|
||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(":id/file")
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Replace a publication's file" })
|
||||
replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.service.replaceFile(id, file);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({ summary: "Remove a publication" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
import { PublicationsController } from "./publications.controller";
|
||||
import { PublicationsRepository } from "./publications.repository";
|
||||
import { PublicationsService } from "./publications.service";
|
||||
import { PublicPublicationsController } from "./public-publications.controller";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Publication]), MinioModule],
|
||||
controllers: [PublicPublicationsController, PublicationsController],
|
||||
providers: [PublicationsRepository, PublicationsService],
|
||||
exports: [PublicationsService],
|
||||
})
|
||||
export class PublicationsModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
|
||||
@Injectable()
|
||||
export class PublicationsRepository extends BaseRepository<Publication> {
|
||||
constructor(
|
||||
@InjectRepository(Publication)
|
||||
repository: Repository<Publication>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Public list: published rows only, in display order. */
|
||||
findPublished(): Promise<Publication[]> {
|
||||
return this.repository.find({
|
||||
where: { published: true },
|
||||
order: { sortOrder: "ASC", publishedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin list: every row, published or not. */
|
||||
override findAll(): Promise<Publication[]> {
|
||||
return this.repository.find({ order: { sortOrder: "ASC" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
PublicationSummary,
|
||||
PUBLICATION_ALLOWED_MIME_TYPES,
|
||||
PUBLICATION_FILE_PREFIX,
|
||||
} from "@edr/types";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { extname } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
import { PublicationsRepository } from "./publications.repository";
|
||||
|
||||
@Injectable()
|
||||
export class PublicationsService {
|
||||
constructor(
|
||||
private readonly repository: PublicationsRepository,
|
||||
private readonly minio: MinioService,
|
||||
) {}
|
||||
|
||||
private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File {
|
||||
if (!file) throw new BadRequestException("No file uploaded");
|
||||
if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
file: Express.Multer.File | undefined,
|
||||
dto: CreatePublicationDto,
|
||||
actorId: string | null,
|
||||
): Promise<Publication> {
|
||||
this.assertAllowedFile(file);
|
||||
|
||||
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
const published = dto.published ?? true;
|
||||
return this.repository.create({
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
category: dto.category ?? null,
|
||||
fileKey: key,
|
||||
fileName: file.originalname,
|
||||
fileMimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
published,
|
||||
publishedAt: published ? new Date() : null,
|
||||
uploadedById: actorId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePublicationDto): Promise<Publication> {
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
const patch: Partial<Publication> = {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.category !== undefined && { category: dto.category }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
};
|
||||
|
||||
if (dto.published !== undefined && dto.published !== existing.published) {
|
||||
patch.published = dto.published;
|
||||
patch.publishedAt = dto.published ? new Date() : null;
|
||||
}
|
||||
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Publication ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */
|
||||
async replaceFile(id: string, file?: Express.Multer.File): Promise<Publication> {
|
||||
this.assertAllowedFile(file);
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
const updated = await this.repository.update(id, {
|
||||
fileKey: key,
|
||||
fileName: file.originalname,
|
||||
fileMimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
});
|
||||
|
||||
await this.minio.deleteFile(existing.fileKey);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.getByIdOrThrow(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Admin list — every row, published or not. */
|
||||
list(): Promise<Publication[]> {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public list — published rows only. No file URL here: a presigned MinIO
|
||||
* URL isn't reachable from the browser (see `fileViewUrl` in the portal's
|
||||
* `apiConfig.ts`); the portal builds each file's URL itself from `id` via
|
||||
* `GET /publications/:id/file`.
|
||||
*/
|
||||
async listPublic(): Promise<PublicationSummary[]> {
|
||||
const rows = await this.repository.findPublished();
|
||||
return rows.map((row) => this.toSummary(row));
|
||||
}
|
||||
|
||||
private toSummary(row: Publication): PublicationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
category: row.category ?? null,
|
||||
fileName: row.fileName,
|
||||
fileMimeType: row.fileMimeType,
|
||||
fileSizeBytes: Number(row.fileSizeBytes),
|
||||
sortOrder: row.sortOrder,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** For the public/staff file route: streams a published row's bytes. */
|
||||
async getPublishedFileStream(
|
||||
id: string,
|
||||
): Promise<{ stream: Readable; record: Publication }> {
|
||||
const record = await this.repository.findById(id);
|
||||
if (!record || !record.published) {
|
||||
throw new NotFoundException(`Publication ${id} not found`);
|
||||
}
|
||||
return { stream: await this.minio.getFileStream(record.fileKey), record };
|
||||
}
|
||||
|
||||
private async getByIdOrThrow(id: string): Promise<Publication> {
|
||||
const record = await this.repository.findById(id);
|
||||
if (!record) throw new NotFoundException(`Publication ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = {
|
||||
options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveRateType — empty container freight', () => {
|
||||
it('splits empty freight from laden freight by direction', () => {
|
||||
expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe(
|
||||
'EMPTY_CONTAINER_IMPORT',
|
||||
);
|
||||
expect(
|
||||
deriveRateType({
|
||||
appliesTo: 'EMPTY_CONTAINER',
|
||||
trigger: 'ALWAYS',
|
||||
tradeDirection: 'EXPORT',
|
||||
}),
|
||||
).toBe('EMPTY_CONTAINER_EXPORT');
|
||||
});
|
||||
|
||||
// UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate
|
||||
// sharing CONTAINER_IMPORT would collide with the laden rate for the same
|
||||
// lane and container type. The distinct rateType is what keeps both fileable.
|
||||
it('never resolves to the laden container rate type', () => {
|
||||
for (const tradeDirection of ['IMPORT', 'EXPORT']) {
|
||||
expect(
|
||||
deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }),
|
||||
).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,8 @@ export function deriveRateType(input: {
|
||||
switch (appliesTo) {
|
||||
case 'CONTAINER':
|
||||
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
|
||||
case 'EMPTY_CONTAINER':
|
||||
return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT';
|
||||
case 'BULK':
|
||||
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
|
||||
case 'INTERCITY':
|
||||
|
||||
@@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => {
|
||||
expect(isBulkQuantityUnit("FLAT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty equipment carries no cargo, so no weighed unit applies — only the box
|
||||
* and the wagon it rides on.
|
||||
*/
|
||||
describe("allowedRateUnits — empty container freight", () => {
|
||||
it("offers per-container and per-wagon only", () => {
|
||||
expect(
|
||||
allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }),
|
||||
).toEqual(["PER_CONTAINER", "PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("never offers a weighed unit, even for a per-item commodity scope", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "EMPTY_CONTAINER",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).not.toContain("PER_ITEM");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,10 @@ function unitsForShape(input: {
|
||||
switch (appliesTo) {
|
||||
case 'CONTAINER':
|
||||
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||
case 'EMPTY_CONTAINER':
|
||||
// Empty equipment carries no cargo to weigh, so the only bases that mean
|
||||
// anything are the box itself and the wagon it rides on.
|
||||
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||
case 'BULK':
|
||||
return ['PER_TON', 'PER_WAGON'];
|
||||
case 'INTERCITY':
|
||||
|
||||
@@ -8,6 +8,12 @@ import { Yard } from './yard.entity';
|
||||
export const RATE_TYPES = [
|
||||
'CONTAINER_IMPORT',
|
||||
'CONTAINER_EXPORT',
|
||||
// Empty equipment moved as freight in its own right — no cargo, priced per
|
||||
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
|
||||
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
|
||||
// would collide with the laden 40ft rate for the same lane.
|
||||
'EMPTY_CONTAINER_IMPORT',
|
||||
'EMPTY_CONTAINER_EXPORT',
|
||||
'BULK_IMPORT',
|
||||
'BULK_EXPORT',
|
||||
'INTERCITY_BULK',
|
||||
@@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number];
|
||||
* lookup and snapshots).
|
||||
*
|
||||
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
|
||||
* - EMPTY_CONTAINER : base rail freight for empty equipment
|
||||
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
|
||||
* - OTHER : trigger-based surcharges (hazard, reefer …)
|
||||
*/
|
||||
export const RATE_APPLIES_TO = [
|
||||
'BULK',
|
||||
'CONTAINER',
|
||||
'EMPTY_CONTAINER',
|
||||
'INTERCITY',
|
||||
'FIRST_MILE',
|
||||
'LAST_MILE',
|
||||
|
||||
@@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
|
||||
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [
|
||||
'BULK',
|
||||
'CONTAINER',
|
||||
'EMPTY_CONTAINER',
|
||||
'INTERCITY',
|
||||
];
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
||||
* container fee then names its container type and a bulk fee its commodity.
|
||||
@@ -381,6 +386,30 @@ export class RatesService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliesTo === 'EMPTY_CONTAINER') {
|
||||
// Northbound repositioning only. Southbound empties are already sold by
|
||||
// the WITH_RETURN surcharge and empty_return_requests; a second path to
|
||||
// the same movement would let the business double-sell it.
|
||||
if (tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException(
|
||||
'An empty container rate is import-only for now.',
|
||||
);
|
||||
}
|
||||
// Size is the entire scope of an empty rate — there is no cargo to narrow
|
||||
// by, so the box type must be named and a commodity must not be.
|
||||
if (!containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'An empty container rate must name the container type it covers.',
|
||||
);
|
||||
}
|
||||
if (cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'An empty container rate cannot be scoped to a bulk cargo type.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number> {
|
||||
|
||||
Reference in New Issue
Block a user