mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod
|
||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||
import { PublicationsModule } from "./modules/publications/publications.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
@@ -232,6 +233,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
LogoSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
PublicationsModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
|
||||
@@ -124,6 +124,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
(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,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -96,3 +96,55 @@ describe('ContractRateScheduleBuilder', () => {
|
||||
expect(s.isEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty and laden freight are separate tariffs on the same lanes. Each
|
||||
* contract's schedule must show only its own, or the printed paper quotes a
|
||||
* price the customer is not being charged.
|
||||
*/
|
||||
describe('ContractRateScheduleBuilder — empty container contracts', () => {
|
||||
const ladenImport = rate({
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
rateValue: 900,
|
||||
originYard: { label: 'Negad' } as never,
|
||||
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||
containerType: { label: '40ft GP' } as never,
|
||||
});
|
||||
|
||||
const emptyImport = rate({
|
||||
appliesTo: 'EMPTY_CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'EMPTY_CONTAINER_IMPORT',
|
||||
rateValue: 250,
|
||||
originYard: { label: 'Negad' } as never,
|
||||
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||
containerType: { label: '40ft GP' } as never,
|
||||
});
|
||||
|
||||
const builder = new ContractRateScheduleBuilder({
|
||||
findLiveRatesDetailed: jest.fn().mockResolvedValue([ladenImport, emptyImport]),
|
||||
} as never);
|
||||
|
||||
it('shows only the empty lane on an empty contract', async () => {
|
||||
const schedule = await builder.build('IMP', 'CON', 'EMPTY');
|
||||
|
||||
expect(schedule.freightLanes).toHaveLength(1);
|
||||
expect(schedule.freightLanes[0].amount).toBe('250');
|
||||
});
|
||||
|
||||
it('shows only the laden lane on a laden contract', async () => {
|
||||
const schedule = await builder.build('IMP', 'CON', 'LADEN');
|
||||
|
||||
expect(schedule.freightLanes).toHaveLength(1);
|
||||
expect(schedule.freightLanes[0].amount).toBe('900');
|
||||
});
|
||||
|
||||
it('treats a contract with no condition as laden', async () => {
|
||||
const schedule = await builder.build('IMP', 'CON');
|
||||
|
||||
expect(schedule.freightLanes).toHaveLength(1);
|
||||
expect(schedule.freightLanes[0].amount).toBe('900');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder {
|
||||
async build(
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
cargoCondition?: string | null,
|
||||
): Promise<RateSchedule> {
|
||||
const isEmpty = cargoCondition === 'EMPTY';
|
||||
const rates = await this.ratesService.findLiveRatesDetailed();
|
||||
|
||||
const freightLanes: RateScheduleRow[] = [];
|
||||
@@ -93,7 +95,7 @@ export class ContractRateScheduleBuilder {
|
||||
|
||||
for (const rate of rates) {
|
||||
if (this.isBaseFreight(rate)) {
|
||||
if (this.baseFreightMatches(rate, direction, freight)) {
|
||||
if (this.baseFreightMatches(rate, direction, freight, isEmpty)) {
|
||||
freightLanes.push(this.laneRow(rate));
|
||||
}
|
||||
continue;
|
||||
@@ -140,6 +142,7 @@ export class ContractRateScheduleBuilder {
|
||||
rate.trigger === 'ALWAYS' &&
|
||||
(rate.appliesTo === 'BULK' ||
|
||||
rate.appliesTo === 'CONTAINER' ||
|
||||
rate.appliesTo === 'EMPTY_CONTAINER' ||
|
||||
rate.appliesTo === 'INTERCITY')
|
||||
);
|
||||
}
|
||||
@@ -148,7 +151,19 @@ export class ContractRateScheduleBuilder {
|
||||
rate: Rate,
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
isEmpty = false,
|
||||
): boolean {
|
||||
// Empty and laden are separate tariffs on the same lanes, so each contract
|
||||
// shows only its own. Without this an empty contract would print the laden
|
||||
// lane prices it is not being charged.
|
||||
if (isEmpty) {
|
||||
return (
|
||||
rate.appliesTo === 'EMPTY_CONTAINER' &&
|
||||
rate.tradeDirection === (direction === 'EXP' ? 'EXPORT' : 'IMPORT')
|
||||
);
|
||||
}
|
||||
if (rate.appliesTo === 'EMPTY_CONTAINER') return false;
|
||||
|
||||
// Domestic contracts price off intercity rates; the freight kind is carried
|
||||
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
|
||||
if (direction === 'DOM') {
|
||||
|
||||
@@ -140,6 +140,8 @@ export class ContractViewModelBuilder {
|
||||
const rateSchedule = await this.rateScheduleBuilder.build(
|
||||
template.direction,
|
||||
template.freight,
|
||||
// Empty bookings print the empty tariff, never the laden lane prices.
|
||||
booking.cargoCondition,
|
||||
);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Public document library for the freight portal (PDFs, Markdown write-ups,
|
||||
* PowerPoint decks about the platform), managed from the backoffice. Each row
|
||||
* is one whole file stored in MinIO under `publications/` — a re-upload
|
||||
* replaces the object and the row's file columns, there is no per-version
|
||||
* history table like `support_documents` has.
|
||||
*/
|
||||
export class Publications3850000000000 implements MigrationInterface {
|
||||
name = 'Publications3850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.publications (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title varchar(200) NOT NULL,
|
||||
description text,
|
||||
category varchar(60),
|
||||
file_key varchar(512) NOT NULL,
|
||||
file_name varchar(255) NOT NULL,
|
||||
file_mime_type varchar(120) NOT NULL,
|
||||
file_size_bytes bigint NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
published boolean NOT NULL DEFAULT true,
|
||||
published_at timestamptz,
|
||||
uploaded_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
// Serves the public list: published rows in display order.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_publications_published_sort
|
||||
ON freight.publications (published, sort_order)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds DJF to `freight.payments_currency_enum` — the only currency column in
|
||||
* the schema backed by a real Postgres enum (every other currency column is
|
||||
* a plain varchar and needed no migration).
|
||||
*
|
||||
* This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD
|
||||
* VALUE` cannot be used within the same transaction that added it (Postgres
|
||||
* restriction, still true on PG 12+), and migrations here run one-per-
|
||||
* transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert
|
||||
* that writes 'DJF' into `payments.currency` to this file.
|
||||
*/
|
||||
export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface {
|
||||
name = 'AddDjfPaymentsCurrency3860000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Postgres cannot drop a single enum value. Reverting would require
|
||||
// recreating the type and every dependent column/constraint — out of
|
||||
// scope for a currency addition; leave it in place.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds the DJF toggle to `manual_payment_settings`, alongside the existing
|
||||
* `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF
|
||||
* invoices are bank-transfer-settleable from day one.
|
||||
*/
|
||||
export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface {
|
||||
name = 'AddDjfManualPaymentSetting3870000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.manual_payment_settings
|
||||
ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* `exchange_settings` was a single-row table holding the USD→ETB fallback
|
||||
* only. Restructures it to one row per currency so DJF (and any future
|
||||
* currency) gets its own fallback rate, source and sync timestamp instead of
|
||||
* a parallel column per currency.
|
||||
*/
|
||||
export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface {
|
||||
name = 'ExchangeSettingsPerCurrency3880000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5);
|
||||
`);
|
||||
// The single pre-existing row was always the USD→ETB fallback.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency
|
||||
ON freight.exchange_settings (currency) WHERE deleted_at IS NULL;
|
||||
`);
|
||||
// Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so
|
||||
// pricing has a usable fallback before the first successful CBE fetch.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF');
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`);
|
||||
await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Empty container import is base rail freight for equipment carrying no cargo,
|
||||
* so it is sold per lane exactly like laden container freight.
|
||||
*
|
||||
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
|
||||
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
|
||||
* Drop-and-recreate is the established shape for this constraint — see
|
||||
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
|
||||
*/
|
||||
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerRateScope3890000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Whether a booking moves cargo or bare equipment.
|
||||
*
|
||||
* EMPTY is container freight carrying nothing — the box itself is the shipment,
|
||||
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
|
||||
* separate column rather than a third `freight_type`: an empty booking is still
|
||||
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
|
||||
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
|
||||
* places whose else-arm means "container".
|
||||
*
|
||||
* Every existing row is LADEN, which the default supplies — no backfill needed.
|
||||
*/
|
||||
export class BookingCargoCondition3900000000000 implements MigrationInterface {
|
||||
name = 'BookingCargoCondition3900000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
|
||||
`);
|
||||
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
|
||||
// freight. Enforced here so no API path can file the combination.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
|
||||
cargo_condition IN ('LADEN', 'EMPTY')
|
||||
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||
)
|
||||
`);
|
||||
|
||||
// The booking queues filter empties out of (and into) the laden lists.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
|
||||
ON freight.bookings (cargo_condition)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Contract paper for empty container import.
|
||||
*
|
||||
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
|
||||
* contract can commit to moving bare equipment.
|
||||
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
|
||||
* resolves for those contracts. It carries no customs variant: an empty box
|
||||
* has no declaration to clear, the same reason intercity is unsuffixed.
|
||||
*/
|
||||
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
|
||||
|
||||
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerContractTemplate3910000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
|
||||
cargo_condition IN ('LADEN', 'EMPTY')
|
||||
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||
)
|
||||
`);
|
||||
|
||||
for (const code of SEEDED_CODES) {
|
||||
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
|
||||
if (!seed) throw new Error(`Missing contract template default for ${code}`);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.contract_templates
|
||||
(id, code, name, description, document_title, whereas_clauses, articles,
|
||||
is_active, is_system, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
|
||||
true, true, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates
|
||||
WHERE code = $1::varchar AND deleted_at IS NULL
|
||||
)`,
|
||||
[
|
||||
seed.code,
|
||||
seed.name,
|
||||
seed.description,
|
||||
seed.documentTitle,
|
||||
JSON.stringify(seed.whereasClauses),
|
||||
JSON.stringify(
|
||||
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
|
||||
[[...SEEDED_CODES]],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -912,6 +912,129 @@ Settle assessed duties and taxes within the period notified by the Service Provi
|
||||
),
|
||||
];
|
||||
|
||||
/* ────────────────────────── IMPORT / EMPTY CONTAINER ─────────────────────── */
|
||||
|
||||
/**
|
||||
* Empty container import — bare equipment railed north from Djibouti for
|
||||
* repositioning inland. Not a variant of the laden import pack: there is no
|
||||
* cargo to describe, no VGM to declare, no commercial documents to lodge and no
|
||||
* customs leg to sell, so the paper is a straight equipment-carriage agreement.
|
||||
* Priced per box by size (20ft / 40ft) and lane, off an EMPTY_CONTAINER_IMPORT
|
||||
* rate.
|
||||
*/
|
||||
const IMPORT_EMPTY_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Empty Container Import Contract",
|
||||
description:
|
||||
"Railway transport of empty containers from Djibouti (DMP/Nagad) to the agreed Ethiopian terminal for repositioning. Priced per container by size; no cargo, no customs clearing.",
|
||||
documentTitle: "Empty Container Transportation Service by Railway",
|
||||
whereasClauses: [
|
||||
"The Client has requested and agreed to the transportation of empty containers from the Djibouti railway terminals (DMP or Nagad) to the agreed Ethiopian destination terminal using the Addis Ababa\u2013Djibouti railway line.",
|
||||
"The containers covered by this Agreement carry no cargo, and the Service Provider is engaged for the carriage of the equipment itself.",
|
||||
"The Service Provider has agreed to transport the empty containers as per the terms of this contract.",
|
||||
],
|
||||
articles: [
|
||||
a(
|
||||
"objective",
|
||||
"Objective and Scope of the Services",
|
||||
`To provide railway transportation services for empty 20ft and/or 40ft containers from the agreed Djibouti loading terminal (DMP or Nagad Railway Station) to the agreed Ethiopian destination terminal.
|
||||
The scope of the services comprises:
|
||||
- Terminal handling and loading of the empty containers onto flat wagons at the Djibouti loading terminal.
|
||||
- Railway transport between the agreed origin and destination terminals.
|
||||
- Unloading of the empty containers at the destination terminal.
|
||||
The containers covered by this Agreement carry no cargo. Any container found to be laden at loading falls outside this Agreement and shall be handled and priced as a laden shipment.`,
|
||||
),
|
||||
a(
|
||||
"client-obligations",
|
||||
"Obligations of the Client",
|
||||
`Give written/email/electronic shipment instructions to the Service Provider stating the number of empty containers by size (20ft and/or 40ft), the loading terminal and the destination terminal.
|
||||
Provide the container release order or equivalent instruction from the container owner or its agent, together with the container numbers, before loading.
|
||||
Warrant that every container tendered is empty, free of residue, and holds no cargo, dunnage or personal effects.
|
||||
Ensure the containers are presented at the loading terminal, in a condition fit for rail carriage, one day before the planned loading date.
|
||||
One flat wagon carries either one 40ft container or two 20ft containers.
|
||||
Book wagons at least five (5) days in advance.
|
||||
Assign representatives at both ends to oversee container handover.
|
||||
Collect the empty containers from the destination terminal within three (3) calendar days from the day following the arrival notice.
|
||||
If the Client fails to collect the containers within the specified period, the Client shall be liable to pay the applicable demurrage, storage and double handling charges of the destination terminal.
|
||||
Settle all charges due under this Agreement in accordance with the agreed payment terms.`,
|
||||
),
|
||||
a(
|
||||
"provider-obligations",
|
||||
"Obligations of the Service Provider",
|
||||
`Provide the agreed number of flat wagons on the agreed loading date, subject to wagon availability and the allocation priority applicable to the booking.
|
||||
Handle and load the empty containers at the Djibouti loading terminal and unload them at the destination terminal.
|
||||
Transport the empty containers to the agreed destination terminal and issue an arrival notice to the Client.
|
||||
Record the condition of each container at handover, and hand over the containers at destination in the condition in which they were received, fair wear and tear from carriage excepted.
|
||||
Issue the consignment note and the interchange documentation for each shipment.
|
||||
Notify the Client without delay of any incident affecting the containers in the Service Provider's custody.`,
|
||||
),
|
||||
a(
|
||||
"liability",
|
||||
"Liability for the Equipment",
|
||||
`The Service Provider's liability under this Agreement is limited to loss of, or physical damage to, the containers while in its custody between loading at the origin terminal and handover at the destination terminal.
|
||||
Because the containers carry no cargo, no cargo liability, cargo insurance obligation or cargo declaration arises under this Agreement.
|
||||
The Service Provider shall not be liable for pre-existing damage recorded at loading, nor for damage arising from a defect in the container itself.
|
||||
The Client shall indemnify the Service Provider against any claim arising from a container tendered as empty that is later found to contain cargo, residue or prohibited goods.`,
|
||||
),
|
||||
a(
|
||||
"force-majeure",
|
||||
"Force Majeure",
|
||||
`Neither party shall be liable for failure to perform its obligations under this Agreement where such failure results from an event beyond its reasonable control, including natural disaster, war, civil unrest, government action, or closure of the railway line or terminals.
|
||||
The affected party shall notify the other in writing within five (5) calendar days of the occurrence and shall resume performance as soon as the event ceases.`,
|
||||
),
|
||||
a(
|
||||
"pricing",
|
||||
"Contract Price and Terms of Payment",
|
||||
`The price is charged per empty container carried, at the agreed rate for each container size (20ft and 40ft) on the agreed origin\u2013destination lane, as set out in the rate schedule to this Agreement.
|
||||
The price covers terminal handling, loading, railway carriage and unloading as described in the Scope of the Services. It excludes any charge levied by the destination terminal after the free period, and any first-mile or last-mile road leg unless separately agreed.
|
||||
Payment shall be made in accordance with the payment terms stated in this Agreement; where the price is quoted in USD and settled in Birr, conversion applies the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment.
|
||||
The Service Provider may revise the rates on prior written notice to the Client.`,
|
||||
),
|
||||
a(
|
||||
"contract-documents",
|
||||
"Contract Documents",
|
||||
`The following form an integral part of this Agreement:
|
||||
- This Agreement and its rate schedule.
|
||||
- The container release order or equivalent instruction from the container owner or its agent.
|
||||
- The shipment instruction given by the Client for each consignment.
|
||||
- The consignment note and interchange documents issued for each shipment.`,
|
||||
),
|
||||
a(
|
||||
"consignment-notes",
|
||||
"Consignment Notes",
|
||||
`A consignment note shall be issued for each shipment, stating the container numbers, sizes, the origin and destination terminals and the recorded condition of each container.
|
||||
The consignment note is evidence of the containers received for carriage and of their condition at handover.`,
|
||||
),
|
||||
a(
|
||||
"amendment",
|
||||
"Amendment",
|
||||
`Any amendment to this Agreement shall be valid only if made in writing and signed by the authorised representatives of both parties.`,
|
||||
),
|
||||
a(
|
||||
"termination",
|
||||
"Termination of Contract",
|
||||
`Either party may terminate this Agreement by giving thirty (30) calendar days' prior written notice to the other party.
|
||||
Either party may terminate this Agreement with immediate effect where the other party commits a material breach and fails to remedy it within fifteen (15) calendar days of written notice.
|
||||
Termination does not affect any obligation accrued before the effective date of termination, including payment for shipments already performed or in transit.`,
|
||||
),
|
||||
a(
|
||||
"effectiveness",
|
||||
"Contract Effectiveness",
|
||||
`This Agreement becomes effective on the date it is signed by the authorised representatives of both parties.`,
|
||||
),
|
||||
a(
|
||||
"duration",
|
||||
"Contract Period",
|
||||
`This Agreement shall remain in force for the period stated in the Agreement, unless terminated earlier in accordance with the Termination article.`,
|
||||
),
|
||||
a(
|
||||
"disputes",
|
||||
"Settlement of Disputes",
|
||||
`The parties shall attempt to settle any dispute arising out of or in connection with this Agreement amicably.
|
||||
Failing amicable settlement, the dispute shall be resolved in accordance with the laws of the Federal Democratic Republic of Ethiopia before the competent courts of Ethiopia.`,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */
|
||||
function splitByCustoms(
|
||||
base: ContractTemplateBase,
|
||||
@@ -944,10 +1067,11 @@ function splitByCustoms(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fourteen templates: import and export each split by customs clearing option
|
||||
* Fifteen templates: import and export each split by customs clearing option
|
||||
* (full, Ethiopian-only, none), intercity
|
||||
* not split at all — it is a domestic Ethiopian movement that crosses no
|
||||
* border, so there is no customs leg to contract for.
|
||||
* border, so there is no customs leg to contract for. Empty container import
|
||||
* is unsplit for the same reason: bare equipment carries no declaration.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"),
|
||||
@@ -956,4 +1080,5 @@ export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"),
|
||||
...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"),
|
||||
{ ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" },
|
||||
{ ...IMPORT_EMPTY_CONTAINER_BASE, code: "IMPORT_EMPTY_CONTAINER" },
|
||||
];
|
||||
|
||||
@@ -2538,6 +2538,11 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:support_content:view",
|
||||
manage: "edr_freight_app:settings:support_content:manage",
|
||||
},
|
||||
// Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice.
|
||||
publications: {
|
||||
view: "edr_freight_app:settings:publications:view",
|
||||
manage: "edr_freight_app:settings:publications:manage",
|
||||
},
|
||||
},
|
||||
support: {
|
||||
agentView: "edr_freight_app:support:agent_view",
|
||||
|
||||
Reference in New Issue
Block a user