diff --git a/.gitignore b/.gitignore index 8fa8bca90..cf36ca979 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # build output **/dist/ .next/ +**/out/ coverage/ *.tsbuildinfo **/*.tsbuildinfo @@ -62,3 +63,6 @@ integration/.it-shards.yaml *.crt secrets/ certs/ +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/CLAUDE.md b/CLAUDE.md index a20fbaee7..f070313f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ copying a pattern across: | `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | | `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | +| `edr-landing` | `@edr/landing` | **Next.js** static export | 5163 | Those are the **fallbacks compiled into the code**, not what you will be running. Every port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite @@ -43,8 +44,16 @@ the whole team and the low ports are contested — see the workspace root `CLAUD Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace packages (see `pnpm-workspace.yaml`). -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. +`apps/edr-landing/` is the public front door at `edrsc.com`: one static page that routes +visitors to the passenger or freight app. It is a Next.js **static export** +(`output: 'export'`), so its build artifact is `out/`, not `.next/`. It depends on no +workspace package — not even `@edr/ui-common`, whose Tailwind 4 tokens do not fit its +Tailwind 3 setup. + +Its two destinations come from `NEXT_PUBLIC_PASSENGER_URL` and `NEXT_PUBLIC_FREIGHT_URL` +(each an origin; the entry path is appended in `src/lib/apps.ts`). A static export inlines +those at **build** time, so they must be passed as Docker build args — setting them in the +runtime environment does nothing. `apps/edr-gps-tracker/` is a separate service with its own `.env.example`. diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c6fe0fa7d..dea1a6949 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 6a64f6199..6cbb3cfd9 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -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 ? { diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts index a7b007617..b993aa987 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 8990e1b43..56d23ce79 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder { async build( direction: ContractDirection, freight: ContractFreight, + cargoCondition?: string | null, ): Promise { + 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') { diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 517934bb9..bcea28541 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -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(); diff --git a/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts new file mode 100644 index 000000000..d44f57931 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts new file mode 100644 index 000000000..8d0f52110 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds DJF to `freight.payments_currency_enum` — the only currency column in + * the schema backed by a real Postgres enum (every other currency column is + * a plain varchar and needed no migration). + * + * This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD + * VALUE` cannot be used within the same transaction that added it (Postgres + * restriction, still true on PG 12+), and migrations here run one-per- + * transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert + * that writes 'DJF' into `payments.currency` to this file. + */ +export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface { + name = 'AddDjfPaymentsCurrency3860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`); + } + + public async down(): Promise { + // Postgres cannot drop a single enum value. Reverting would require + // recreating the type and every dependent column/constraint — out of + // scope for a currency addition; leave it in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts new file mode 100644 index 000000000..6353e8664 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the DJF toggle to `manual_payment_settings`, alongside the existing + * `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF + * invoices are bank-transfer-settleable from day one. + */ +export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface { + name = 'AddDjfManualPaymentSetting3870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings + ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts b/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts new file mode 100644 index 000000000..3677e6269 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `exchange_settings` was a single-row table holding the USD→ETB fallback + * only. Restructures it to one row per currency so DJF (and any future + * currency) gets its own fallback rate, source and sync timestamp instead of + * a parallel column per currency. + */ +export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface { + name = 'ExchangeSettingsPerCurrency3880000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5); + `); + // The single pre-existing row was always the USD→ETB fallback. + await queryRunner.query(` + UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency + ON freight.exchange_settings (currency) WHERE deleted_at IS NULL; + `); + // Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so + // pricing has a usable fallback before the first successful CBE fetch. + await queryRunner.query(` + INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at) + SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF'); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts b/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts new file mode 100644 index 000000000..8f14a7123 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts @@ -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 { + 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 { + 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 + ) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts b/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts new file mode 100644 index 000000000..34754e4f2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts b/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts new file mode 100644 index 000000000..950fa64c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index fa00fb521..6d416a7e3 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -126,7 +126,7 @@ export class FilterInvoiceDto { @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) - @IsIn(["USD", "ETB"]) + @IsIn(["ETB", "USD", "DJF"]) currency?: "USD" | "ETB"; @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index 012d5f8db..86cad4277 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,7 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -291,20 +291,24 @@ export class AdditionalChargeService { } /** - * Amount converted to the other of ETB/USD, via the existing shared + * Amount converted to a second reference currency, via the existing shared * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` * rate) — same mechanism `booking-wagon-cancellation.service.ts` and - * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * warehouse fee pricing already use. ETB converts to USD and vice versa + * (unchanged behaviour); any other supported currency (DJF) converts to + * USD, the system's pivot currency. Null on an unsupported currency, or if * the rate feed is down — this is a display convenience, not the payable * amount, so a failure here must never break the charge list. */ private async convertAmount( charge: AdditionalCharge, ): Promise<{ amount: number; currency: string } | null> { - if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; - const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + const from = charge.currency?.toUpperCase(); + if (!(CURRENCY_CODES as readonly string[]).includes(from ?? '')) return null; + const source = from as CurrencyCode; + const target: CurrencyCode = source === 'ETB' ? 'USD' : source === 'USD' ? 'ETB' : 'USD'; try { - const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + const amount = await this.exchangeService.convert(Number(charge.amount), source, target); return { amount: Math.round(amount * 100) / 100, currency: target }; } catch (err) { this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts index e3e97f301..1f0da457a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index ba1aaa875..dcecb0c14 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let exchangeService: { getRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; @@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => { }; exchangeService = { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + // Delegates to `getRate` so a test that reassigns + // `exchangeService.getRate.mockResolvedValue(...)` gets a consistent + // rate table without also having to touch this mock. + getRateTable: jest.fn(async (target: string) => { + const rate = await exchangeService.getRate('USD', target); + return { ETB: rate, USD: rate, DJF: rate }; + }), }; service = new BookingPricingService( @@ -324,7 +331,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking })), } as never, { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -572,7 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { } as never, { findById: jest.fn() } as never, { findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -707,7 +714,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { })), } as never, { findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, @@ -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).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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 29104bbce..4a745e16d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -143,8 +143,9 @@ export class BookingPricingService { const ruleResult = await this.ruleEngineService.evaluate(evalInput); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; // H15: a booking created under a contract prices from that contract's FROZEN // rate snapshots (the agreed rates), not the live rate of the day. Loaded @@ -213,7 +214,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -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 | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): 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 | null, containerTypeId: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): Promise { 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( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 983564a51..a35e29663 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -8,7 +8,7 @@ import { NotFoundException, } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -114,6 +114,15 @@ interface PricedFee { * The cycle is repeatable by construction: the rebooked booking is a normal * PAID booking, so it can itself be partially cancelled again. */ + +/** Validates a stored currency string against the supported set, defaulting to USD. */ +function toCurrencyCode(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; +} + @Injectable() export class BookingWagonCancellationService { private readonly logger = new Logger(BookingWagonCancellationService.name); @@ -1697,10 +1706,10 @@ export class BookingWagonCancellationService { */ private async priceFee(booking: Booking, cut: RequestedCut): Promise { const raw = await this.priceFeeInRateCurrency(booking, cut); - // Bill in the booking's own currency (rates are configured in USD; ETB - // bookings pay ETB) — same USD→ETB conversion booking pricing applies. - const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; - const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; + // Bill in the booking's own currency (rates are configured in USD; a + // non-USD booking converts) — same conversion booking pricing applies. + const target = toCurrencyCode(booking.paymentCurrency); + const from = toCurrencyCode(raw.currency); if (from === target) return raw; const fx = await this.exchangeService.getRate(from, target); return { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ea901bb16..83daa0286 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 = { ...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], diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index a9aca53dd..a88c3f4c3 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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; /** diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts index 1365158b1..c3417d62d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -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'; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 9cf728a0d..b457dd259 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts index 50a912a1d..9aa071fc9 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -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()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index 11cf412fa..d760333af 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record = { 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 { 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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index f94e5d52f..4cbf81316 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -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" diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 04be6460f..1367e8c8a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -3,7 +3,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; @@ -95,9 +95,9 @@ export class ContractPricingService { (r) => !r.shippingLineCompanyId, ); const currency = contract.paymentCurrency; - const isEtb = currency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + const usdToTarget = + currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode); + const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget)); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index ac400d9d3..5d8c24060 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 6c5390fee..dc9f8ac7b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 9f596bbef..c0d4e65ee 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -98,7 +98,7 @@ export class CreateBookingRequestDto { 'Billing currency for the shipment GL will book. Intercity is always ETB.', }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) paymentCurrency?: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 88ab8beb7..1020dff13 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index cdf6b4ecf..b776333b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts index 7bd109430..c6edd436f 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => const frozenByCode = ( snap: ContractRateSnapshot | null, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null => ( BookingPricingService.prototype as unknown as { @@ -28,14 +28,14 @@ const frozenByCode = ( m: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ) => ContractRateSnapshot | null; } ).frozenRateByCode( snap ? new Map([['CONTAINER_20FT', snap]]) : null, 'CONTAINER_20FT', bookingCurrency, - usdToEtb, + fx, ); describe('per-shipment billing currency', () => { @@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => { it('converts a USD snapshot for an ETB booking instead of dropping it', () => { // The old behaviour returned null here, which silently re-priced the // booking at live rates and lost the agreed contract price. - expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000); }); it('converts a grandfathered ETB snapshot back for a USD booking', () => { - expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); + expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400); + }); + + it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => { + // 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845. + expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040); }); it('passes a matching-currency snapshot through untouched', () => { const snap = snapshot('USD', 400); - expect(frozenByCode(snap, 'USD', 1)).toBe(snap); + expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap); }); it('refuses to price off an unusable exchange rate', () => { // Converting with 0 would zero the whole line. - expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); - expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull(); + }); + + it('refuses to price off a currency the rate table has no entry for', () => { + expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull(); }); it('returns null when there is no snapshot', () => { - expect(frozenByCode(null, 'ETB', 150)).toBeNull(); + expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts index 98e87007c..4343732b7 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -1,13 +1,14 @@ -import { IsNumber, Max, Min } from "class-validator"; +import { IsNumber, Min } from "class-validator"; /** - * Operator-set USD→ETB fallback. Bounded well outside any plausible published - * rate but far short of a fat-fingered magnitude error — this value multiplies - * real invoice amounts whenever CBE is unreachable. + * Operator-set X→ETB fallback for one currency. The upper bound is enforced + * per currency in the controller (see `RATE_BOUNDS`) rather than here, since + * USD's plausible range (~100-300) and DJF's (~0.5-2) differ by two orders of + * magnitude — this value multiplies real invoice amounts whenever CBE is + * unreachable. */ export class UpdateExchangeSettingDto { @IsNumber({ maxDecimalPlaces: 6 }) - @Min(1) - @Max(10_000) + @Min(0.000001) fallbackRate!: number; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts index 1e1f4ad66..e1fc99780 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm"; export type ExchangeFallbackSource = "AUTO" | "MANUAL"; /** - * Single-row table holding the USD→ETB fallback used when the CBE endpoint is - * unreachable. The live CBE rate always wins; this is only consulted on - * failure, and is overwritten by every successful fetch so it tracks the last - * known good rate. + * One row per foreign currency, holding the X→ETB fallback used when the CBE + * endpoint is unreachable for that currency. The live CBE rate always wins; + * this is only consulted on failure, and is overwritten by every successful + * fetch so it tracks the last known good rate. */ @Entity({ schema: "freight", name: "exchange_settings" }) export class ExchangeSetting extends BaseEntity { - /** USD→ETB rate served while the CBE endpoint is failing. */ + /** The foreign currency this row's fallback applies to, e.g. `USD`, `DJF`. */ + @Column({ name: "currency", type: "varchar", length: 5 }) + currency!: string; + + /** currency→ETB rate served while the CBE endpoint is failing for it. */ @Column({ name: "fallback_rate", type: "numeric", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts index fb126f969..a52db2010 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service"; /** * The app's single `ExchangeModule` registration shape: CBE endpoint config - * from `app.cbeExchange`, with the DB-backed fallback wired in. + * from `app.cbeExchange`, with the DB-backed per-currency fallback wired in. * * `ExchangeModule` is registered per-feature-module (bookings, contracts, * warehouses), so this keeps the three call sites identical rather than @@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule { settings: ExchangeSettingsService, ): ExchangeOptions => ({ ...(config.get("app.cbeExchange") ?? {}), - loadFallbackRate: () => settings.loadFallbackRate(), - saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + loadFallbackRate: (code) => settings.loadFallbackRate(code), + saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate), }), }); } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts new file mode 100644 index 000000000..e18bc830e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts @@ -0,0 +1,102 @@ +import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; + +/** + * The CBE feed quotes every currency it publishes against ETB in one fetch — + * this is a fixture of that shape (trimmed to USD + DJF, the two the app + * actually reads). Verified live against the real feed on 2026-09-04. + */ +const CBE_FIXTURE = [ + { + Date: '2026-09-04', + ExchangeRate: [ + { + transactionalSelling: 163.4365, + transactionalBuying: 160.2319, + currency: { CurrencyCode: 'USD' }, + }, + { + transactionalSelling: 0.9203, + transactionalBuying: 0.9022, + currency: { CurrencyCode: 'DJF' }, + }, + // CBE publishes 0 for a currency it isn't quoting cash-selling that + // day — must not be picked up as a usable rate. + { transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } }, + ], + }, +]; + +function mockFetchOnce(payload: unknown): jest.Mock { + const fn = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + (global as unknown as { fetch: typeof fetch }).fetch = fn as never; + return fn; +} + +describe('CbeExchangeProvider — multi-currency', () => { + it('parses every quoted currency out of one fetch, not just USD', async () => { + const fetchMock = mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' }); + const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' }); + + expect(usdToEtb).toBeCloseTo(163.4365); + expect(djfToEtb).toBeCloseTo(0.9203); + // Both rates came from the SAME cached fetch — one HTTP call serves + // every currency, not one per currency. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow( + /No CBE rate available for ZZZ/, + ); + }); + + it('only ever answers for X→ETB — everything else is derived upstream', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull(); + await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull(); + }); +}); + +describe('ExchangeService — USD↔DJF pivot', () => { + it('derives USD→DJF by pivoting through ETB, the provider’s base currency', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('USD', 'DJF'); + + // 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand. + expect(rate).toBeCloseTo(163.4365 / 0.9203, 4); + expect(rate).toBeCloseTo(177.59, 1); + }); + + it('derives the inverse, DJF→USD, from the same pivot', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('DJF', 'USD'); + + expect(rate).toBeCloseTo(0.9203 / 163.4365, 6); + }); + + it('getRateTable resolves every supported currency into the target in one call', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const fx = await service.getRateTable('DJF'); + + expect(fx.DJF).toBe(1); + expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4); + expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 001fc90d2..0e0736561 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { CurrentUser } from "@edr/api-common"; +import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff } from "../../common/booking-guards"; @@ -8,6 +8,31 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; import { ExchangeSettingsService } from "./exchange-settings.service"; +/** + * Sane manual-rate ceiling per currency — bounded well outside any plausible + * published rate but far short of a fat-fingered magnitude error. USD trades + * in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF + * itself is worth roughly 1/177th of a USD). + */ +const RATE_BOUNDS: Record = { + ETB: 1, + USD: 10_000, + DJF: 100, +}; + +const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB"); + +function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] { + const code = currency?.toUpperCase(); + const match = FOREIGN_CURRENCIES.find((c) => c === code); + if (!match) { + throw new BadRequestException( + `Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`, + ); + } + return match; +} + @ApiTags("exchange-settings") @ApiBearerAuth() @Controller("exchange-settings") @@ -17,37 +42,51 @@ export class ExchangeSettingsController { @Get() @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]) @ApiOperation({ - summary: "Current USD→ETB fallback rate and CBE feed health", + summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency", }) - async get() { - const setting = await this.service.get(); - const status = this.service.getFeedStatus(); + async list() { + const settings = await this.service.list(); + const byCurrency = new Map(settings.map((s) => [s.currency, s])); - return { - fallbackRate: setting.fallbackRate, - fallbackSource: setting.fallbackSource, - lastSyncedAt: setting.lastSyncedAt, - updatedById: setting.updatedById, - feed: status, - }; + return FOREIGN_CURRENCIES.map((code) => { + const setting = byCurrency.get(code); + return { + currency: code, + fallbackRate: setting?.fallbackRate ?? null, + fallbackSource: setting?.fallbackSource ?? null, + lastSyncedAt: setting?.lastSyncedAt ?? null, + updatedById: setting?.updatedById ?? null, + feed: this.service.getFeedStatus(code), + }; + }); } - @Patch() + @Patch(":currency") @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: - "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", + "Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)", }) async update( + @Param("currency") currency: string, @Body() dto: UpdateExchangeSettingDto, @CurrentUser() user: TCurrentUser, ) { + const code = assertSupportedCurrency(currency); + if (dto.fallbackRate > RATE_BOUNDS[code]) { + throw new BadRequestException( + `Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`, + ); + } + const updated = await this.service.setManualRate( + code, dto.fallbackRate, user?.id ?? null, ); return { + currency: updated.currency, fallbackRate: updated.fallbackRate, fallbackSource: updated.fallbackSource, lastSyncedAt: updated.lastSyncedAt, diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index e0b670292..df36821bc 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -1,16 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { CurrencyCode } from "@edr/api-common"; import { Repository } from "typeorm"; import { ExchangeSetting } from "./entities/exchange-setting.entity"; /** - * Rate used before the row exists and before the first successful CBE fetch — - * the CBE USD transactional selling rate on 2026-08-04. + * Rate used before a currency's row exists and before its first successful + * CBE fetch. USD is the CBE transactional selling rate on 2026-08-04; DJF is + * the CBE transactional selling rate on 2026-09-04 (CBE started being read + * for DJF then). */ -const SEED_FALLBACK_RATE = 162.4165; +const SEED_FALLBACK_RATES: Partial> = { + USD: 162.4165, + DJF: 0.9203, +}; -/** Health of the CBE feed, as surfaced to the backoffice. */ +/** Health of the CBE feed for one currency, as surfaced to the backoffice. */ export interface ExchangeFeedStatus { /** Rate most recently observed, whatever its source. */ rate: number | null; @@ -22,9 +28,17 @@ export interface ExchangeFeedStatus { lastError: string | null; } +const EMPTY_FEED_STATUS: ExchangeFeedStatus = { + rate: null, + source: null, + lastSuccessAt: null, + lastError: null, +}; + /** - * Owns the single `exchange_settings` row: the USD→ETB fallback used when the - * CBE endpoint is unreachable. + * Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) — + * each holding the currency→ETB fallback used when the CBE endpoint is + * unreachable for it. * * The live CBE rate is always preferred. This value is only read on failure, * and every successful fetch overwrites it, so it tracks the last known good @@ -35,107 +49,113 @@ export class ExchangeSettingsService { private readonly logger = new Logger(ExchangeSettingsService.name); /** - * Feed health, recorded from the exchange provider's callbacks rather than - * read off an injected `ExchangeService`. The provider is registered several - * times (bookings, contracts, warehouses), so no single instance sees every - * fetch — and injecting one here would be circular, since those - * registrations inject *this* service. + * Feed health per currency, recorded from the exchange provider's + * callbacks rather than read off an injected `ExchangeService`. The + * provider is registered several times (bookings, contracts, warehouses), + * so no single instance sees every fetch — and injecting one here would be + * circular, since those registrations inject *this* service. */ - private feed: ExchangeFeedStatus = { - rate: null, - source: null, - lastSuccessAt: null, - lastError: null, - }; + private feed = new Map(); constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} - /** Health of the CBE feed as last observed by any provider instance. */ - getFeedStatus(): ExchangeFeedStatus { - return { ...this.feed }; + /** Health of the CBE feed for `code` as last observed by any provider instance. */ + getFeedStatus(code: CurrencyCode): ExchangeFeedStatus { + return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) }; } - /** The settings row, created at the seed rate on first access. */ - async get(): Promise { - const existing = await this.repository.findOne({ where: {} }); + /** The settings row for `code`, created at the seed rate on first access. */ + async get(code: CurrencyCode): Promise { + const existing = await this.repository.findOne({ where: { currency: code } }); if (existing) return existing; return this.repository.save( this.repository.create({ - fallbackRate: SEED_FALLBACK_RATE, + currency: code, + fallbackRate: SEED_FALLBACK_RATES[code] ?? 1, fallbackSource: "AUTO", lastSyncedAt: null, }), ); } + /** Every currency's settings row, for the backoffice settings list. */ + async list(): Promise { + return this.repository.find({ order: { currency: "ASC" } }); + } + /** - * Reads the stored fallback for the exchange provider. Returns `null` on any - * failure so the provider falls through to its own static default rather - * than propagating a database error into a pricing call. + * Reads the stored fallback for `code`, for the exchange provider. Returns + * `null` on any failure so the provider falls through to its own static + * default rather than propagating a database error into a pricing call. */ - async loadFallbackRate(): Promise { + async loadFallbackRate(code: CurrencyCode): Promise { // Only reached when the live fetch failed, so this call is itself the - // signal that the feed is down. + // signal that the feed is down for this currency. try { - const { fallbackRate } = await this.get(); + const { fallbackRate } = await this.get(code); const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; - this.feed = { - ...this.feed, - rate: usable ? fallbackRate : this.feed.rate, + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { + ...previous, + rate: usable ? fallbackRate : previous.rate, source: "stored", - lastError: this.feed.lastError ?? "CBE endpoint unreachable", - }; + lastError: previous.lastError ?? "CBE endpoint unreachable", + }); return usable ? fallbackRate : null; } catch (err) { const message = (err as Error).message; - this.feed = { ...this.feed, source: "stored", lastError: message }; - this.logger.warn(`Could not read stored exchange fallback: ${message}`); + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { ...previous, source: "stored", lastError: message }); + this.logger.warn( + `Could not read stored exchange fallback for ${code}: ${message}`, + ); return null; } } /** - * Records a freshly fetched live rate as the new fallback. Marked `AUTO`, - * overwriting a manual entry — a manual rate is a stopgap for while CBE is - * down, so a working CBE feed takes precedence again. + * Records a freshly fetched live rate as the new fallback for `code`. + * Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap + * for while CBE is down, so a working CBE feed takes precedence again. */ - async saveFallbackRate(rate: number): Promise { + async saveFallbackRate(code: CurrencyCode, rate: number): Promise { // Only called after a successful fetch, so the feed is confirmed healthy. - this.feed = { + this.feed.set(code, { rate, source: "live", lastSuccessAt: new Date().toISOString(), lastError: null, - }; + }); - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "AUTO", lastSyncedAt: new Date(), updatedById: null, }); - this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); + this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/${code}`); } /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ async setManualRate( + code: CurrencyCode, rate: number, updatedById?: string | null, ): Promise { - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "MANUAL", updatedById: updatedById ?? null, }); this.logger.warn( - `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, + `Exchange fallback for ${code} set manually to ${rate} ETB/${code} by ${updatedById ?? "unknown user"}`, ); - return this.get(); + return this.get(code); } } diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts index 51e99614a..d3b24dd43 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = { { key: 'paymentCurrency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'serviceTypeId', label: 'Service type', type: 'text' }, // Routes are one-to-many on contract_routes, so these filter via EXISTS diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index d4d635f6f..4afe28771 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'minAmount', label: 'Min total', type: 'text' }, { key: 'maxAmount', label: 'Max total', type: 'text' }, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts index 59739823c..e7a9963f2 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts @@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'search', label: 'Search order or transaction ID', type: 'text' }, ], diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 1bd22c7c0..526dbafe6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto { export class OverviewBillingKpisDto { @ApiProperty() revenueMtdEtb!: number; @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() revenueMtdDjf!: number; @ApiProperty() pendingPayments!: number; @ApiProperty() successfulPaymentsMtd!: number; } @@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto { @ApiProperty({ example: '2026-06-01' }) date!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewRecentBookingDto { @@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto { @ApiProperty() bookingsCreated!: number; @ApiProperty() revenueEtb!: number; @ApiProperty() revenueUsd!: number; + @ApiProperty() revenueDjf!: number; @ApiProperty() tons!: number; } @@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto { @ApiProperty() label!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewTonsTrendPointDto { @@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto { @ApiProperty() freightType!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewHeatmapCellDto { diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 6908498bb..d3100df9e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -265,6 +265,7 @@ export class OverviewRepository { async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; + revenueMtdDjf: number; pendingPayments: number; successfulPaymentsMtd: number; }> { @@ -279,6 +280,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueMtdUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueMtdDjf", + ) .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .where("payment.status = :status", { status: "success" }) .andWhere( @@ -298,6 +303,7 @@ export class OverviewRepository { return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; @@ -370,7 +376,7 @@ export class OverviewRepository { days: number, dirs?: string[], offsetDays = 0, - ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -386,6 +392,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, @@ -394,12 +404,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") - .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -510,7 +521,7 @@ export class OverviewRepository { async getPaymentsByMethod( dirs?: string[], ): Promise< - { method: string; count: number; amountEtb: number; amountUsd: number }[] + { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -525,6 +536,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`, + "amountDjf", + ) .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") @@ -533,6 +548,7 @@ export class OverviewRepository { count: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -540,6 +556,7 @@ export class OverviewRepository { count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -580,6 +597,7 @@ export class OverviewRepository { bookingsCreated: number; revenueEtb: number; revenueUsd: number; + revenueDjf: number; tons: number; }> { const bookingScope = directionScopeSql("booking.trade_direction", dirs); @@ -605,13 +623,17 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( windowSql("COALESCE(payment.paid_at, payment.created_at)"), { days, offsetDays }, ) .andWhere(paymentScope.sql, paymentScope.params) - .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(), this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") @@ -626,6 +648,7 @@ export class OverviewRepository { bookingsCreated, revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + revenueDjf: Number(revenueRow?.revenueDjf ?? 0), tons: Number(tonsRow?.tons ?? 0), }; } @@ -634,7 +657,7 @@ export class OverviewRepository { async getRevenueByDirection( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -648,6 +671,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -656,12 +683,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .groupBy("booking.trade_direction") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -669,7 +697,7 @@ export class OverviewRepository { async getRevenueByFreightType( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -683,6 +711,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -691,12 +723,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.freight_type") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -734,6 +767,7 @@ export class OverviewRepository { freightType: string; amountEtb: number; amountUsd: number; + amountDjf: number; }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); @@ -750,6 +784,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -765,6 +803,7 @@ export class OverviewRepository { freightType: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -772,6 +811,7 @@ export class OverviewRepository { freightType: row.freightType, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 971de03cb..39d4757e7 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() usdEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" }) + @IsOptional() + @IsBoolean() + djfEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index a18f97279..862456241 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "usd_enabled", type: "boolean", default: true }) usdEnabled!: boolean; + /** Manual settlement allowed for DJF invoices. */ + @Column({ name: "djf_enabled", type: "boolean", default: true }) + djfEnabled!: boolean; + /** IAM user id of the last operator to change either toggle. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index efb43fa91..dc397cf79 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -4,16 +4,23 @@ import { Repository } from "typeorm"; import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; -/** The two currencies an invoice can be settled by hand in. */ -export type ManualPaymentCurrency = "ETB" | "USD"; +/** The currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD" | "DJF"; + +const FIELD_BY_CURRENCY: Record = { + ETB: "etbEnabled", + USD: "usdEnabled", + DJF: "djfEnabled", +}; /** * Owns the single `manual_payment_settings` row: whether Finance may settle * invoices by hand, per currency. * * Defaults mirror how the platform behaved before the toggles existed — USD - * has always been bank-transfer-only so it starts ON; ETB manual settlement is - * the new capability and starts OFF, so enabling it is a deliberate act. + * and DJF have always been bank-transfer-capable so they start ON; ETB manual + * settlement is the new capability and starts OFF, so enabling it is a + * deliberate act. */ @Injectable() export class ManualPaymentSettingsService { @@ -30,7 +37,7 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true }), + this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }), ); } @@ -40,31 +47,34 @@ export class ManualPaymentSettingsService { const enabled: ManualPaymentCurrency[] = []; if (setting.etbEnabled) enabled.push("ETB"); if (setting.usdEnabled) enabled.push("USD"); + if (setting.djfEnabled) enabled.push("DJF"); return enabled; } /** Whether one currency may be settled by hand right now. */ async isEnabled(currency: string | null | undefined): Promise { const upper = currency?.toUpperCase(); - if (upper !== "ETB" && upper !== "USD") return false; + const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency]; + if (!field) return false; const setting = await this.get(); - return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + return setting[field]; } - /** Flip either toggle; an omitted field leaves that currency unchanged. */ + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, updatedById?: string | null, ): Promise { const current = await this.get(); await this.repository.update(current.id, { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, ); return updated; } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 0cf3b886c..b5cdf582f 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" -type Currency = "ETB" | "USD" +type Currency = "ETB" | "USD" | "DJF" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @Entity({ schema: 'freight', name: 'payments' }) @@ -25,7 +25,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) method!: PaymentMethod - @Column({ type: "enum", enum: ["ETB", "USD"] }) + @Column({ type: "enum", enum: ["ETB", "USD", "DJF"] }) currency!: Currency @Column({ type: "numeric" }) diff --git a/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts new file mode 100644 index 000000000..313ff182c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts new file mode 100644 index 000000000..677b08c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreatePublicationDto } from "./create-publication.dto"; + +export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {} diff --git a/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts new file mode 100644 index 000000000..e627e638c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts new file mode 100644 index 000000000..8a95ffb39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.controller.ts b/apps/edr-freight-api/src/modules/publications/publications.controller.ts new file mode 100644 index 000000000..79f18e9e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.module.ts b/apps/edr-freight-api/src/modules/publications/publications.module.ts new file mode 100644 index 000000000..46e612ebe --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/publications/publications.repository.ts b/apps/edr-freight-api/src/modules/publications/publications.repository.ts new file mode 100644 index 000000000..315f630bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.repository.ts @@ -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 { + constructor( + @InjectRepository(Publication) + repository: Repository, + ) { + super(repository); + } + + /** Public list: published rows only, in display order. */ + findPublished(): Promise { + return this.repository.find({ + where: { published: true }, + order: { sortOrder: "ASC", publishedAt: "DESC" }, + }); + } + + /** Admin list: every row, published or not. */ + override findAll(): Promise { + return this.repository.find({ order: { sortOrder: "ASC" } }); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.service.ts b/apps/edr-freight-api/src/modules/publications/publications.service.ts new file mode 100644 index 000000000..e7ff2b2a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.service.ts @@ -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 { + 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 { + const existing = await this.getByIdOrThrow(id); + + const patch: Partial = { + ...(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 { + 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 { + await this.getByIdOrThrow(id); + await this.repository.softDelete(id); + } + + /** Admin list — every row, published or not. */ + list(): Promise { + 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 { + 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 { + const record = await this.repository.findById(id); + if (!record) throw new NotFoundException(`Publication ${id} not found`); + return record; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 550475599..d50b20e9c 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = { options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ], }; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts index 5902453a0..45c3b0abd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -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'); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 894080e81..2d7e3463a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts index 39d6174de..2a56c1755 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index fd7754844..207359b70 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index cc57e4c65..dd6432cfa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index a3361e526..87618bcdb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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.`, diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 6d7084a96..6c22d37b5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -14,7 +14,7 @@ export class GenerateInvoiceDto { @ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) billingCurrency?: 'ETB' | 'USD'; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index c59cd0617..80a81b13a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; @@ -430,8 +430,11 @@ export class WarehouseFeeService { }; } - private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { - return currency === 'ETB' ? 'ETB' : 'USD'; + private normalizeCurrency(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 5ddc99cca..2a3bd17aa 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -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" }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 020d093ad..7e90f6dff 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8d3b46228..c57af9a8a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -60,6 +60,7 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage" import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; +import PublicationsPage from "./pages/publications/PublicationsPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage"; @@ -1211,6 +1212,19 @@ const App = () => { } /> + + + + } + /> = { DRAFT: { label: "Draft", color: "gray" }, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 65c9719ed..678913f78 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Coins, Truck } from "lucide-react"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; @@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => `${Number(amount).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: currencyDecimals(currency), + maximumFractionDigits: currencyDecimals(currency), })} ${currency === "ETB" ? "Birr (ETB)" : currency}`; /** diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx index 318ce3f9b..a03c29bec 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { OperationDatePicker } from "@edr/ui-common"; +import { OperationDatePicker, currencyDecimals } from "@edr/ui-common"; import { api } from "@/auth/http"; import { api as rpc } from "@/services/api"; @@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({ {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} {cancellation.wagonsCancelled} wagon(s) · credit{" "} - {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))} Shipment day diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx index 4bca7cced..4f50c5921 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx @@ -8,6 +8,7 @@ import { api } from "@/auth/http"; import { useAuth } from "@/auth/useAuth"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal"; import { canRebookWagonCancellations, @@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({ {Number(r.wagonsCancelled)} wagon(s) · credit{" "} - {formatMoney(Number(r.creditAmount), r.feeCurrency, 2)} + {formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))} {chip.label} @@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({ Cancelled {formatDate(r.createdAt)} {r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""} {Number(r.feeAmount) > 0 - ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${ + ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}${ r.feePaidAt ? " paid" : " unpaid" }` : ""} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index 047d1361f..fb47ace60 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -39,7 +39,7 @@ import { import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; -const CURRENCIES = ["ETB", "USD"]; +const CURRENCIES = ["ETB", "USD", "DJF"]; const STATUS_META: Record< Freight.ClearanceChargeStatus, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 4925edb7b..308867caa 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -345,7 +345,7 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); // IMPORT bookings pick ETB or USD — starts empty so the choice is // deliberate (required before pricing). Everything else is forced to ETB. - const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">(""); + const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">(""); // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -1144,7 +1144,7 @@ export default function GlCreateBookingForm() { ]); // Only IMPORT actually chooses — the rest bill ETB regardless of the state. - const effectiveCurrency: "USD" | "ETB" = + const effectiveCurrency: "USD" | "ETB" | "DJF" = isImport && paymentCurrency ? paymentCurrency : "ETB"; const currencyError = isImport && !paymentCurrency @@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() { {requestCurrencyLocked ? "The customer chose the billing currency on the shipment request — it cannot be changed." : isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + ? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online." : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 313645db3..ea4ec0cb3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -1554,7 +1554,7 @@ function SecondDutyStep({ /> setCurrency(v ?? "ETB")} size="sm" @@ -2063,7 +2063,7 @@ function DutyStep({ /> setCurrency(v ?? "ETB")} size="sm" diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 16a055831..1f64a6478 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -582,6 +582,15 @@ export const buildSidebarSections = ( FREIGHT_PERMS.settings.supportContent.manage, ], }, + { + label: "Publications", + href: "/dashboard/publications", + icon: , + permission: [ + FREIGHT_PERMS.settings.publications.view, + FREIGHT_PERMS.settings.publications.manage, + ], + }, { label: "Audit logs", href: "/dashboard/audit-logs", diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx index f1a74ac4e..aa5857840 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx @@ -18,7 +18,7 @@ function formatDateLabel(date: string) { return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } -function formatAmount(value: number, currency: "ETB" | "USD") { +function formatAmount(value: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx index e1ceaec1c..3464141fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard"; function formatAmount(amount: number | null, currency: string | null) { if (amount == null) return "—"; - const code = currency === "USD" ? "USD" : "ETB"; return new Intl.NumberFormat("en-US", { style: "currency", - currency: code, + currency: currency || "ETB", maximumFractionDigits: 0, }).format(amount); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx index e076b1f18..307d9fc22 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page"; import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; import { CountUp } from "./CountUp"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, @@ -13,7 +13,7 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") { } /** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */ -function formatCompactCurrency(amount: number, currency: "ETB" | "USD") { +function formatCompactCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx index 52b8bf161..c7e2a1fe5 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx @@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip"; import { OverviewPaymentChart } from "../OverviewPaymentChart"; import { overviewChartColors } from "../overview.styles"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx index 5170b345d..635a78800 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react'; +import { currencyDecimals } from '@edr/ui-common'; import { useAccrualDashboard } from '@/hooks/useWarehouses'; import { warehouseService } from '@/services/warehouse.service'; @@ -15,7 +16,8 @@ const ALERT_META: Record = { }; function money(amount: number, currency: string): string { - return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; + const decimals = currencyDecimals(currency); + return `${amount.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${currency}`; } function freeDaysLabel(row: AccrualDashboardRow): string { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index cb2cbc658..058fc96bd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -113,7 +113,7 @@ function Row({ label, value }: { label: string; value: string }) { /** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { const { toast } = useToast(); - const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD'); + const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD' | 'DJF'>('USD'); const enabledId = opened ? inventoryId ?? undefined : undefined; const { data, isLoading } = useQuery( api.warehouses.feePreview.queryOptions({ @@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa setBillingCurrency(value as 'ETB' | 'USD')} + onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')} data={[ { value: 'USD', label: 'USD' }, { value: 'ETB', label: 'Birr' }, + { value: 'DJF', label: 'DJF' }, ]} disabled={Boolean(activeInvoice)} /> diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts index d5fca3c6e..b7c26c152 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; const QUERY_KEY = ["exchangeSettings"]; +/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */ export const useExchangeSettingsQuery = () => useQuery({ queryKey: QUERY_KEY, - queryFn: () => exchangeSettingsService.get(), + queryFn: () => exchangeSettingsService.list(), // Feed health is only interesting while it is being looked at. staleTime: 30_000, refetchOnWindowFocus: true, @@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => { const { handleError } = useErrorHandler(t); return useMutation({ - mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate), + mutationFn: ({ currency, rate }: { currency: string; rate: number }) => + exchangeSettingsService.setFallbackRate(currency, rate), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); toast.success( diff --git a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts index b49f33376..cbb5f5996 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts @@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => { return useMutation({ mutationFn: ( - patch: Partial>, + patch: Partial< + Pick + >, ) => manualPaymentSettingsService.update(patch), onSuccess: (data) => { queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data); diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 6ed13636a..a6db563cd 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -221,7 +221,7 @@ export function useOnTimeDispatch() { } /** Live per-item fee accrual (storage/demurrage) with alerts. */ -export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { +export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') { return useQuery({ queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), @@ -598,7 +598,7 @@ export const useUpdateFeeRule = () => export const useDeleteFeeRule = () => useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); -export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') { +export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' | 'DJF' = 'USD') { return useQuery({ queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency], queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data), @@ -649,7 +649,7 @@ export function useGenerateInvoice() { }: { inventoryId: string; confirmZero?: boolean; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: 'ETB' | 'USD' | 'DJF'; }) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data), onSuccess, }); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 4cf7f0c79..22ac1b016 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -433,6 +433,10 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:support_content:view", manage: "edr_freight_app:settings:support_content:manage", }, + publications: { + view: "edr_freight_app:settings:publications:view", + manage: "edr_freight_app:settings:publications:manage", + }, }, staff: { roles: { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index a75e242e1..579c8f290 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { formatDateTime, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { BookingDetail } from "@/types/booking"; import { @@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() { const kpis: KpiItem[] = [ { label: "Total value", - value: formatMoney(amount, booking.paymentCurrency, 2), + value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)), hint: booking.paymentStatus, icon: Wallet, color: "edr-green", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx index 1a18d4cf0..7d1685328 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth"; import { PageContainer, PageHeader } from "@/components/page"; import { toDayString } from "@/hooks/useListControls"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { DataTable, @@ -166,7 +167,7 @@ export default function WagonCancellationsPage() { header: () => Fee, cell: ({ row }) => ( - {formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)} + {formatMoney(row.original.feeAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))} ), }, @@ -175,7 +176,7 @@ export default function WagonCancellationsPage() { header: () => Credit, cell: ({ row }) => ( - {formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)} + {formatMoney(row.original.creditAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))} ), }, @@ -347,7 +348,7 @@ export default function WagonCancellationsPage() { {voiding.booking?.reference ?? voiding.bookingId} ·{" "} {voiding.wagonsCancelled} wagon(s) · fee{" "} - {formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)} + {formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))} The pending fee is dropped and the wagons stay on the booking. diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index 455a6473e..b62d405fe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -82,6 +82,7 @@ const CONTRACT_KIND_OPTIONS = [ const CURRENCY_OPTIONS = [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, + { value: "DJF", label: "DJF" }, ]; /** value = `${sortBy}:${sortOrder}` for the sort Select. */ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx index bcfadb698..c0d577d89 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => { + diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 2b319de30..de8d46ce3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [ options: [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, + { value: "DJF", label: "DJF" }, ], }, { @@ -235,8 +236,23 @@ export default function InvoicesPanel() { const { data: exchangeSettings } = useExchangeSettingsQuery(); const etbCollected = summary?.ETB ?? 0; const usdCollected = summary?.USD ?? 0; - const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate; - const etbFromUsd = rate ? usdCollected * rate : null; + const djfCollected = summary?.DJF ?? 0; + const rateFor = (currency: string) => { + const setting = exchangeSettings?.find((s) => s.currency === currency); + return setting?.feed?.rate ?? setting?.fallbackRate ?? null; + }; + const usdRate = rateFor("USD"); + const djfRate = rateFor("DJF"); + const etbFromUsd = usdRate ? usdCollected * usdRate : null; + const etbFromDjf = djfRate ? djfCollected * djfRate : null; + const totalEtb = etbCollected + (etbFromUsd ?? 0) + (etbFromDjf ?? 0); + const totalHint = [ + "ETB", + etbFromUsd !== null ? "USD" : null, + etbFromDjf !== null ? "DJF" : null, + ] + .filter(Boolean) + .join(" + "); const columns: ColumnDef[] = useMemo( () => [ @@ -356,8 +372,8 @@ export default function InvoicesPanel() { items={[ { label: "Total collected", - hint: etbFromUsd !== null ? "ETB + USD" : "ETB only", - value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"), + hint: totalHint, + value: formatMoney(totalEtb, "ETB"), icon: CircleDollarSign, color: "edr-green", }, @@ -373,6 +389,12 @@ export default function InvoicesPanel() { icon: Landmark, color: "violet", }, + { + label: "Collected in DJF", + value: formatMoney(djfCollected, "DJF"), + icon: Landmark, + color: "orange", + }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index 8b3e58a5f..eeceae91b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -274,7 +274,7 @@ function ConfirmCell({ export default function UsdPaymentsPanel({ currency, }: { - currency: "USD" | "ETB"; + currency: "USD" | "ETB" | "DJF"; }) { const navigate = useNavigate(); // Namespaced: the ETB and USD tabs share this panel and live on the same URL diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 2c30fb11f..868137ba3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -27,6 +27,7 @@ import { useQuery } from "@tanstack/react-query"; import { KpiStrip } from "@/components/page"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { @@ -149,7 +150,7 @@ export default function PaymentsPanel() { header: () => Amount, cell: ({ row }) => ( - {formatMoney(row.original.amount, row.original.currency, 2)} + {formatMoney(row.original.amount, row.original.currency, currencyDecimals(row.original.currency))} ), }, diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx new file mode 100644 index 000000000..a4ca7e5a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export interface DeletePublicationDialogProps { + title: string; + onConfirm?: () => void; + children: ReactNode; +} + +export default function DeletePublicationDialog({ + title, + onConfirm, + children, +}: DeletePublicationDialogProps) { + return ( + + {children} + + + + Delete publication? + + This will remove{" "} + {title} from the + public library. It stops being downloadable immediately. + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx new file mode 100644 index 000000000..f7e6a8b2d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx @@ -0,0 +1,211 @@ +import type { Publication } from "@edr/types"; +import { useMutation } from "@tanstack/react-query"; +import { Loader2, UploadCloud } from "lucide-react"; +import { useRef, useState, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/services/api"; + +export interface EditPublicationDialogProps { + mode?: "create" | "edit"; + publication?: Publication; + children: ReactNode; +} + +const ACCEPT = + ".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +export default function EditPublicationDialog({ + mode = "create", + publication, + children, +}: EditPublicationDialogProps) { + const isEdit = mode === "edit"; + const fileInputRef = useRef(null); + + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(publication?.title ?? ""); + const [description, setDescription] = useState(publication?.description ?? ""); + const [category, setCategory] = useState(publication?.category ?? ""); + const [file, setFile] = useState(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + const createMutation = useMutation(api.publications.create.mutationOptions()); + const updateMutation = useMutation(api.publications.update.mutationOptions()); + const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions()); + const pending = + createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending; + + const reset = () => { + setTitle(publication?.title ?? ""); + setDescription(publication?.description ?? ""); + setCategory(publication?.category ?? ""); + setFile(null); + setProgress(null); + setError(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleSubmit = async () => { + setError(null); + if (!title.trim()) { + setError("Title is required."); + return; + } + if (!isEdit && !file) { + setError("Choose a file to upload."); + return; + } + + const meta = { + title: title.trim(), + description: description.trim() || undefined, + category: category.trim() || undefined, + }; + + try { + if (isEdit && publication) { + await updateMutation.mutateAsync({ id: publication.id, dto: meta }); + if (file) { + await replaceFileMutation.mutateAsync({ + id: publication.id, + file, + onProgress: setProgress, + }); + } + } else if (file) { + await createMutation.mutateAsync({ file, meta, onProgress: setProgress }); + } + setOpen(false); + if (!isEdit) reset(); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong. Try again."); + } finally { + setProgress(null); + } + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + {children} + + + + + {isEdit ? "Edit publication" : "New publication"} + + + {isEdit + ? "Update this document's title, description or category, or replace its file." + : "Upload a PDF, Markdown or PowerPoint file for the public library."} + + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. EDR Freight Platform Guide" + /> +
+ +
+ + setCategory(e.target.value)} + placeholder="e.g. Guides, Reports" + /> +
+ +
+ +