[0];
+ }
+
+ private yardLabel(yard?: { label?: string; code?: string } | null): string {
+ return this.valueOrDash(yard?.label ?? yard?.code);
+ }
+
+ private formatDate(value?: Date | string | null): string {
+ if (!value) return 'β';
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return 'β';
+ return date.toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+ }
+
+ private valueOrDash(value?: string | number | null): string {
+ if (value === undefined || value === null || value === '') return 'β';
+ return String(value);
+ }
+}
+
+// Re-export for callers that want the role union without importing the entity.
+export type { ContractSignerRole };
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
index cb3440739..64319612a 100644
--- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
@@ -25,6 +25,26 @@
Equipment return: {{pricing.equipmentReturn}}
{{/if}}
+ {{#if pricing.unitRates}}
+ Unit Rate Schedule
+
+ The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
+ totals are determined per shipment at booking time; no total contract value is fixed at this stage.
+
+
+
+ | Item | Unit price |
+
+
+ {{#each pricing.unitRates}}
+
+ | {{label}} |
+ {{currency}} {{unitPrice}} / {{unit}} |
+
+ {{/each}}
+
+
+ {{else}}
Charges
+ {{/if}}
Terms of payment
Unless otherwise agreed in writing, the Client shall settle the contract value in
diff --git a/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts b/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts
new file mode 100644
index 000000000..e635d19c1
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts
@@ -0,0 +1,55 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Seeds the admin-configurable "contract validity periods" setting (days). Stored
+ * as a dropdown_settings row whose options each hold a day count in `value`, so
+ * backoffice manages them through the existing Dropdown Settings UI and the
+ * contract staff-accept dialog only offers the configured durations.
+ */
+export class SeedContractValidityPeriods1792000000004
+ implements MigrationInterface
+{
+ name = 'SeedContractValidityPeriods1792000000004';
+ private readonly code = 'contract_validity_periods';
+ private readonly options: Array<{ value: string; label: string }> = [
+ { value: '180', label: '6 months' },
+ { value: '365', label: '1 year' },
+ { value: '730', label: '2 years' },
+ ];
+
+ public async up(queryRunner: QueryRunner): Promise {
+ const existing = await queryRunner.query(
+ `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
+ [this.code],
+ );
+ if (existing.length > 0) return;
+
+ const inserted = await queryRunner.query(
+ `INSERT INTO freight.dropdown_settings (code, label, description, multiple)
+ VALUES ($1, $2, $3, false)
+ RETURNING id;`,
+ [
+ this.code,
+ 'Contract Validity Periods (days)',
+ 'Validity durations (in days) a staff can choose when accepting a submitted contract.',
+ ],
+ );
+ const settingId = inserted[0].id;
+
+ for (let i = 0; i < this.options.length; i++) {
+ const opt = this.options[i];
+ await queryRunner.query(
+ `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
+ VALUES ($1, $2, $3, $4);`,
+ [settingId, opt.value, opt.label, i],
+ );
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DELETE FROM freight.dropdown_settings WHERE code = $1;`,
+ [this.code],
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts
new file mode 100644
index 000000000..b2026b753
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts
@@ -0,0 +1,78 @@
+import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
+
+/**
+ * Create the freight.last_mile_container_allocations table β container allocation
+ * records linking last-mile deliveries with containers and vehicles.
+ */
+export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
+ if (exists) return;
+
+ await queryRunner.createTable(
+ new Table({
+ name: 'freight.last_mile_container_allocations',
+ columns: [
+ {
+ name: 'id',
+ type: 'uuid',
+ isPrimary: true,
+ default: 'gen_random_uuid()',
+ },
+ { name: 'last_mile_id', type: 'uuid', isNullable: false },
+ { name: 'container_id', type: 'uuid', isNullable: false },
+ { name: 'vehicle_id', type: 'uuid', isNullable: true },
+ {
+ name: 'container_type',
+ type: 'text',
+ isNullable: false,
+ },
+ {
+ name: 'quantity',
+ type: 'integer',
+ default: 1,
+ isNullable: false,
+ },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.last_mile_container_allocations',
+ new TableForeignKey({
+ columnNames: ['last_mile_id'],
+ referencedTableName: 'freight.last_mile',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ }),
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.last_mile_container_allocations',
+ new TableForeignKey({
+ columnNames: ['vehicle_id'],
+ referencedTableName: 'freight.vehicles',
+ referencedColumnNames: ['id'],
+ onDelete: 'SET NULL',
+ }),
+ );
+
+ await queryRunner.query(
+ `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ const exists = await queryRunner.hasTable('freight.last_mile_container_allocations');
+ if (exists) {
+ await queryRunner.dropTable('freight.last_mile_container_allocations');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts
new file mode 100644
index 000000000..aeedae2b1
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts
@@ -0,0 +1,51 @@
+import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
+
+export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
+ name = 'AddPostPaymentCompletedColumn1810000000004';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
+ if (firstMileTable) {
+ const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
+ if (!hasColumn) {
+ await queryRunner.addColumn(
+ 'freight.first_mile_deliveries',
+ new TableColumn({
+ name: 'is_post_payment_completed',
+ type: 'boolean',
+ default: false,
+ isNullable: false,
+ })
+ );
+ }
+ }
+
+ const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
+ if (lastMileTable) {
+ const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
+ if (!hasColumn) {
+ await queryRunner.addColumn(
+ 'freight.last_mile_deliveries',
+ new TableColumn({
+ name: 'is_post_payment_completed',
+ type: 'boolean',
+ default: false,
+ isNullable: false,
+ })
+ );
+ }
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
+ if (lastMileTable) {
+ await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
+ }
+
+ const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
+ if (firstMileTable) {
+ await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts
new file mode 100644
index 000000000..98fa8b228
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts
@@ -0,0 +1,19 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.vehicles
+ ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
+ ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.vehicles
+ DROP COLUMN IF EXISTS estimated_distance_km,
+ DROP COLUMN IF EXISTS actual_distance_km;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts
new file mode 100644
index 000000000..65a3e764b
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts
@@ -0,0 +1,109 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Freight billing β `invoices` + `invoice_lines` tables.
+ *
+ * Matches:
+ * - billing/entities/invoice.entity.ts
+ * - billing/entities/invoice-line.entity.ts
+ *
+ * The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
+ * enum-type name (`__enum`) so the entity's `type: "enum"`
+ * column resolves to it without an explicit `enumName`.
+ */
+export class CreateInvoices1821000000002 implements MigrationInterface {
+ name = "CreateInvoices1821000000002";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ const typeExists = await queryRunner.query(
+ `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`,
+ );
+
+ if (!typeExists.length) {
+ await queryRunner.query(`
+ CREATE TYPE freight.invoices_status_enum AS ENUM (
+ 'DRAFT',
+ 'PENDING',
+ 'PAID',
+ 'OVERDUE',
+ 'CANCELLED',
+ 'REFUNDED'
+ );
+ `);
+ }
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.invoices (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ invoice_number varchar(64) NOT NULL,
+ company_id uuid NOT NULL,
+ company_profile_id uuid NOT NULL,
+ total_amount numeric(14, 2) NOT NULL,
+ currency varchar(8) NOT NULL DEFAULT 'ETB',
+ status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
+ source varchar(255) NOT NULL,
+ source_id varchar(255) NOT NULL,
+ type varchar(255) NOT NULL,
+ issued_at timestamptz,
+ payment_id uuid,
+ due_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ CONSTRAINT pk_invoices PRIMARY KEY (id),
+ CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
+ CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
+ REFERENCES freight.companies (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
+ REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
+ REFERENCES freight.payments (id) ON DELETE SET NULL
+ );
+ `);
+
+ await queryRunner.query(
+ `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
+ );
+
+ await queryRunner.query(`
+ CREATE TABLE freight.invoice_lines (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ invoice_id uuid NOT NULL,
+ charge_type varchar NOT NULL,
+ description varchar(255),
+ quantity numeric(12, 2) NOT NULL DEFAULT 1,
+ unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
+ amount numeric(14, 2) NOT NULL,
+ currency varchar(8) NOT NULL DEFAULT 'ETB',
+ metadata jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
+ CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
+ REFERENCES freight.invoices (id) ON DELETE CASCADE
+ );
+ `);
+
+ await queryRunner.query(
+ `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
+ await queryRunner.query(
+ `DROP TYPE IF EXISTS freight.invoices_status_enum;`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts
new file mode 100644
index 000000000..95ea3db1b
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts
@@ -0,0 +1,129 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Government bookings now bill to a real seeded government company + an explicit
+ * importer/exporter profile, instead of carrying a null company + free-text
+ * institution. This migration:
+ *
+ * 1. Adds `companies.kind` (commercial | government).
+ * 2. Seeds the Ethiopian government entities + their importer/exporter
+ * profiles (mirrors src/seed/data/gov-companies.data.ts β keep in sync).
+ * 3. Backfills every booking with a NULL company_id / company_profile_id so
+ * the NOT NULL constraints below can be applied:
+ * - NULL company_id β the default government company.
+ * - NULL company_profile_id β the company's profile matching the booking
+ * trade direction; else any profile of the company; else the default
+ * government importer profile.
+ * 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
+ */
+export class AddCompanyKindAndGovBookingLinks1821000000003
+ implements MigrationInterface
+{
+ name = "AddCompanyKindAndGovBookingLinks1821000000003";
+
+ // Mirrors src/seed/data/gov-companies.data.ts
+ private readonly govCompanies = [
+ { id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
+ { id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
+ { id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
+ { id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
+ { id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
+ { id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
+ ];
+
+ private get defaultCompanyId(): string {
+ return this.govCompanies[0].id;
+ }
+ private get defaultImporterProfileId(): string {
+ return this.govCompanies[0].im;
+ }
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // 1. kind column
+ await queryRunner.query(
+ `ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
+ );
+
+ // 2. seed government companies + importer/exporter profiles (idempotent)
+ for (const g of this.govCompanies) {
+ await queryRunner.query(
+ `INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
+ VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
+ ON CONFLICT ("id") DO NOTHING`,
+ [g.id, g.name, g.tin, g.email, g.phone],
+ );
+ await queryRunner.query(
+ `INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
+ VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
+ ON CONFLICT ("id") DO NOTHING`,
+ [g.im, g.id, g.imRef, g.ex, g.exRef],
+ );
+ }
+
+ // 3a. backfill NULL company_id β default government company
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
+ [this.defaultCompanyId],
+ );
+
+ // 3b. backfill NULL company_profile_id β profile matching trade direction
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" b
+ SET "company_profile_id" = cp."id"
+ FROM "freight"."company_profiles" cp
+ WHERE b."company_profile_id" IS NULL
+ AND cp."company_id" = b."company_id"
+ AND cp."deleted_at" IS NULL
+ AND cp."type" = CASE b."trade_direction"
+ WHEN 'IMPORT' THEN 'importer'
+ WHEN 'EXPORT' THEN 'exporter'
+ ELSE NULL END`,
+ );
+
+ // 3c. fallback β any profile of the booking's company
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" b
+ SET "company_profile_id" = (
+ SELECT cp."id" FROM "freight"."company_profiles" cp
+ WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
+ ORDER BY cp."created_at" ASC LIMIT 1)
+ WHERE b."company_profile_id" IS NULL
+ AND EXISTS (
+ SELECT 1 FROM "freight"."company_profiles" cp
+ WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
+ );
+
+ // 3d. final fallback β default government importer profile
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
+ [this.defaultImporterProfileId],
+ );
+
+ // 4. enforce NOT NULL
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
+ );
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
+ );
+ // Seeded government rows are intentionally left in place.
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts
new file mode 100644
index 000000000..9f0e8e7bd
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts
@@ -0,0 +1,47 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Make the payment projection source-agnostic so any domain (not just bookings)
+ * can own a payment intent.
+ *
+ * - `payments.type` enum `('booking')` β `varchar(50)`. It now stores the
+ * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
+ * new domain no longer needs an enum migration to write its intents.
+ * - adds `payments.reference_type varchar(40)` β the gateway reference type
+ * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
+ * path can query the provider without hardcoding it.
+ *
+ * Matches payment/entities/payment.entity.ts.
+ */
+export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
+ name = "MakePaymentsTypeGeneric1821000000004";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
+ );
+ await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
+
+ await queryRunner.query(
+ `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
+ );
+
+ // Restore the single-value enum. Any non-'booking' rows would block the cast;
+ // collapse them first so the down migration is safe.
+ await queryRunner.query(
+ `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
+ );
+ await queryRunner.query(
+ `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts b/apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts
new file mode 100644
index 000000000..7995e5023
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts
@@ -0,0 +1,357 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * ContractβBooking separation (additive phase). Introduces a first-class
+ * `freight.contracts` aggregate that owns the legal/commercial agreement (scope
+ * + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`.
+ *
+ * Purely additive: no legacy columns are dropped here. The data backfill and
+ * legacy-column removal happen in a later cutover migration.
+ *
+ * See docs/new-doc.md Β§5.
+ */
+export class CreateContracts1822000000000 implements MigrationInterface {
+ name = 'CreateContracts1822000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // ββ contracts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contracts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ reference VARCHAR(64) NOT NULL UNIQUE,
+
+ company_id UUID,
+ company_profile_id UUID,
+ is_government BOOLEAN NOT NULL DEFAULT FALSE,
+ government_institution VARCHAR(255),
+
+ contract_kind VARCHAR(20) NOT NULL,
+ renewal_of_id UUID REFERENCES freight.contracts(id),
+ trade_direction VARCHAR(10) NOT NULL,
+ freight_type VARCHAR(20) NOT NULL,
+
+ service_type_id UUID NOT NULL,
+ payment_currency VARCHAR(5) NOT NULL,
+ customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ customs_clearing_agent VARCHAR(200),
+ equipment_return VARCHAR(20),
+
+ first_mile_pickup_address TEXT,
+ first_mile_pickup_lat NUMERIC(10,7),
+ first_mile_pickup_lng NUMERIC(10,7),
+ last_mile_delivery_address TEXT,
+ last_mile_delivery_lat NUMERIC(10,7),
+ last_mile_delivery_lng NUMERIC(10,7),
+
+ is_hazardous BOOLEAN NOT NULL DEFAULT FALSE,
+ is_reefer BOOLEAN NOT NULL DEFAULT FALSE,
+
+ estimated_shipment_date TIMESTAMPTZ,
+ contract_validity_days INT,
+ contract_valid_from TIMESTAMPTZ,
+ contract_valid_until TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ,
+
+ status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
+ clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE',
+ clearance_cycle_number INT NOT NULL DEFAULT 0,
+
+ pricing_breakdown JSONB,
+ pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES',
+
+ contract_type VARCHAR(20),
+ contract_template_key VARCHAR(128),
+ contract_generated_at TIMESTAMPTZ,
+ contract_summary TEXT,
+ version_number INT NOT NULL DEFAULT 1,
+ financial_terms JSONB,
+
+ approved_by_staff_id UUID,
+ approved_by_staff_at TIMESTAMPTZ,
+ signed_by_director_id UUID,
+ signed_by_director_at TIMESTAMPTZ,
+ signed_by_ceo_id UUID,
+ signed_by_ceo_at TIMESTAMPTZ,
+ customer_signed_at TIMESTAMPTZ,
+ fully_executed_at TIMESTAMPTZ,
+ locked_at TIMESTAMPTZ,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_company ON freight.contracts(company_id);`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`);
+
+ // ββ contract_routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_routes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ origin_yard_id UUID NOT NULL,
+ destination_yard_id UUID NOT NULL,
+ km NUMERIC(10,2),
+ sort_order SMALLINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id)
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`);
+
+ // ββ contract_cargo_scope βββββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ container_size VARCHAR(10),
+ cargo_type_id UUID,
+ cargo_free_text VARCHAR(200),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size)
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`);
+
+ // ββ contract_signatures ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_signatures (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ role VARCHAR(20) NOT NULL,
+ signer_display_name VARCHAR(255) NOT NULL,
+ signature_file_id UUID,
+ consent_text TEXT,
+ signed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_signatures_contract ON freight.contract_signatures(contract_id);`);
+
+ // ββ contract_approval_steps ββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_approval_steps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ step_order SMALLINT NOT NULL DEFAULT 0,
+ required_role VARCHAR(40) NOT NULL,
+ blocks_role VARCHAR(40),
+ status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ acted_by_staff_id UUID,
+ acted_at TIMESTAMPTZ,
+ note TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`);
+
+ // ββ contract_rate_snapshots ββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ rate_id UUID,
+ rate_code VARCHAR(64) NOT NULL,
+ description VARCHAR(255),
+ unit_price NUMERIC(14,2) NOT NULL,
+ unit_of_measure VARCHAR(32) NOT NULL,
+ currency VARCHAR(5) NOT NULL,
+ container_size VARCHAR(10),
+ is_surcharge BOOLEAN DEFAULT FALSE,
+ conditional_on VARCHAR(32),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`);
+
+ // ββ contract_review_notes ββββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_review_notes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ note_type VARCHAR(40) NOT NULL,
+ body TEXT NOT NULL,
+ author_role VARCHAR(20),
+ author_user_id UUID,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`);
+
+ // ββ contract_clearance_cycles ββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ cycle_number INT NOT NULL,
+ status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS',
+ booking_id UUID,
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ clearance_ready_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number)
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`);
+
+ // ββ contract_document_review (pre-booking clearance, Path B) βββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.contract_document_review (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
+ setting_code VARCHAR(128) NOT NULL,
+ file_key VARCHAR(128) NOT NULL,
+ file_record_id UUID,
+ status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ note TEXT,
+ uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
+ reviewed_by_staff_id UUID,
+ reviewed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_contract_document_review_doc
+ UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key)
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`);
+
+ // ββ clearance_milestones (GL tracking) βββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.clearance_milestones (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE,
+ contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE,
+ clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
+ milestone_code VARCHAR(64) NOT NULL,
+ milestone_label VARCHAR(255) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ owner_region VARCHAR(5),
+ triggered_by_doc BOOLEAN DEFAULT FALSE,
+ triggered_at TIMESTAMPTZ,
+ triggered_by_user_id UUID,
+ note TEXT,
+ sort_order SMALLINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`);
+ // booking-scoped and contract-cycle-scoped uniqueness for milestone codes
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking
+ ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL;
+ `);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle
+ ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL;
+ `);
+
+ // ββ booking_container_units (per-unit container detail) ββββββββββββββββββ
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.booking_container_units (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE,
+ container_number VARCHAR(64) NOT NULL,
+ seal_number VARCHAR(64),
+ vgm_tons NUMERIC(10,3) NOT NULL,
+ is_hazardous BOOLEAN DEFAULT FALSE,
+ is_reefer BOOLEAN DEFAULT FALSE,
+ sort_order SMALLINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number)
+ );
+ `);
+
+ // ββ ALTER bookings βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
+ await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`);
+ await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`);
+ await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`);
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`);
+ // One active booking per ONE_TIME contract. Postgres forbids a subquery in an
+ // index predicate, so we denormalize the contract kind onto the booking and
+ // predicate on that. The column is stamped at booking creation from the
+ // contract; the app layer (ContractBookingService) is the primary guard and
+ // this index is the backstop.
+ await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract
+ ON freight.bookings (contract_id)
+ WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED')
+ AND contract_id IS NOT NULL
+ AND contract_kind = 'ONE_TIME';
+ `);
+
+ // ββ ALTER booking_container ββββββββββββββββββββββββββββββββββββββββββββββ
+ await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`);
+ await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`);
+ await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`);
+
+ // ββ ALTER booking_document_review (denormalized contract link) βββββββββββ
+ await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
+
+ // ββ Extend file_upload_fields with phased GL metadata ββββββββββββββββββββ
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`);
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`);
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`);
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`);
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`);
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`);
+ await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`);
+
+ await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`);
+
+ await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`);
+ await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`);
+ await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`);
+
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`);
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`);
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`);
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`);
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`);
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`);
+
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts
new file mode 100644
index 000000000..981918f25
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts
@@ -0,0 +1,53 @@
+import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
+
+export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
+ name = 'CreateImportDjiboutiOperations1822000000000';
+
+ async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'import_djibouti_operations',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
+ { name: 'train_schedule_id', type: 'uuid', isUnique: true },
+ { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
+ { name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
+ { name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
+ { name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
+ { name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
+ { name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
+ { name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
+ { name: 'notes', type: 'text', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createIndex(
+ 'freight.import_djibouti_operations',
+ new TableIndex({
+ name: 'idx_import_djibouti_operations_schedule',
+ columnNames: ['train_schedule_id'],
+ }),
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.import_djibouti_operations',
+ new TableForeignKey({
+ columnNames: ['train_schedule_id'],
+ referencedTableName: 'train_schedules',
+ referencedSchema: 'freight',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ }),
+ );
+ }
+
+ async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.import_djibouti_operations', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts b/apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts
new file mode 100644
index 000000000..552e4affd
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts
@@ -0,0 +1,175 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Data backfill for the contractβbooking separation (docs/new-doc.md Β§17).
+ *
+ * For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a
+ * `freight.contracts` row from its contract-phase columns, copy its routes
+ * (contract_route_lines β contract_routes, dropping quantity), and point the
+ * contract + every child shipment booking (linked via booking_orders) at it.
+ *
+ * Per Β§19 item 1, historical ONE_TIME bookings that went through the full
+ * contract flow get a contract parent inserted and `contract_id` set on the same
+ * booking row (no row split).
+ *
+ * Idempotent: skips bookings that already have `contract_id` set, and matches a
+ * synthesised contract by a deterministic `CTR-` reference.
+ */
+export class BackfillContractsFromBookings1823000000000
+ implements MigrationInterface
+{
+ name = 'BackfillContractsFromBookings1823000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase
+ // columns. Reference is derived from the source booking id so re-runs are
+ // idempotent (ON CONFLICT DO NOTHING on the unique reference).
+ await queryRunner.query(`
+ INSERT INTO freight.contracts (
+ reference, company_id, company_profile_id, is_government, government_institution,
+ contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
+ customs_clearing_enabled, customs_clearing_agent, equipment_return,
+ first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
+ last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
+ is_hazardous, is_reefer, estimated_shipment_date,
+ contract_validity_days, contract_valid_from, contract_valid_until, expires_at,
+ status, clearance_status, clearance_cycle_number,
+ pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
+ contract_summary, version_number,
+ approved_by_staff_id, approved_by_staff_at,
+ signed_by_director_id, signed_by_director_at,
+ signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
+ created_at, updated_at
+ )
+ SELECT
+ 'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
+ 'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
+ b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
+ b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
+ b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
+ b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
+ b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at,
+ CASE
+ WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE'
+ WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED'
+ WHEN b.status IN ('EXPIRED') THEN 'EXPIRED'
+ WHEN b.status IN ('CANCELLED') THEN 'CANCELLED'
+ WHEN b.status IN ('REJECTED') THEN 'REJECTED'
+ ELSE 'CONTRACT_ACTIVE'
+ END,
+ CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END,
+ 0,
+ b.pricing_breakdown,
+ b.contract_type, b.contract_template_key, b.contract_generated_at,
+ b.contract_summary, COALESCE(b.version_number, 1),
+ b.approved_by_staff_id, b.approved_by_staff_at,
+ b.signed_by_director_id, b.signed_by_director_at,
+ b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
+ b.created_at, b.updated_at
+ FROM freight.bookings b
+ WHERE b.booking_type = 'GENERAL_CONTRACT'
+ ON CONFLICT (reference) DO NOTHING;
+ `);
+
+ // 2. Copy each general contract's route lines into contract_routes (no qty).
+ await queryRunner.query(`
+ INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at)
+ SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now()
+ FROM freight.contract_route_lines crl
+ JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text
+ ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
+ `);
+
+ // 3. Point the general-contract booking itself at its new contract, and stamp
+ // the denormalized contract_kind for the active-booking index.
+ await queryRunner.query(`
+ UPDATE freight.bookings b
+ SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
+ FROM freight.contracts c
+ WHERE c.reference = 'CTR-' || b.id::text
+ AND b.booking_type = 'GENERAL_CONTRACT'
+ AND b.contract_id IS NULL;
+ `);
+
+ // 4. Point each child shipment booking (spawned via booking_orders) at the
+ // same contract as its parent general contract.
+ await queryRunner.query(`
+ UPDATE freight.bookings child
+ SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
+ FROM freight.booking_orders bo
+ JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text
+ WHERE child.id = bo.booking_id
+ AND child.contract_id IS NULL;
+ `);
+
+ // 5. Historical ONE_TIME bookings that completed the contract flow: synthesise
+ // a contract parent and point the same booking row at it (no row split).
+ await queryRunner.query(`
+ INSERT INTO freight.contracts (
+ reference, company_id, company_profile_id, is_government, government_institution,
+ contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
+ customs_clearing_enabled, customs_clearing_agent, equipment_return,
+ first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
+ last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
+ is_hazardous, is_reefer, estimated_shipment_date,
+ contract_validity_days, contract_valid_from, contract_valid_until,
+ status, clearance_status, clearance_cycle_number,
+ pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
+ contract_summary, version_number,
+ approved_by_staff_id, approved_by_staff_at,
+ signed_by_director_id, signed_by_director_at,
+ signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
+ created_at, updated_at
+ )
+ SELECT
+ 'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
+ 'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
+ b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
+ b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
+ b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
+ b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
+ b.contract_validity_days, b.contract_valid_from, b.contract_valid_until,
+ 'FULLY_EXECUTED', 'NOT_APPLICABLE', 0,
+ b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at,
+ b.contract_summary, COALESCE(b.version_number, 1),
+ b.approved_by_staff_id, b.approved_by_staff_at,
+ b.signed_by_director_id, b.signed_by_director_at,
+ b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
+ b.created_at, b.updated_at
+ FROM freight.bookings b
+ WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
+ AND b.contract_id IS NULL
+ AND b.contract_generated_at IS NOT NULL
+ ON CONFLICT (reference) DO NOTHING;
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings b
+ SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER'
+ FROM freight.contracts c
+ WHERE c.reference = 'CTR-' || b.id::text
+ AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
+ AND b.contract_id IS NULL;
+ `);
+
+ // 6. Build a single route per ONE_TIME contract from the booking's own
+ // origin/destination (general contracts already got their routes in step 2).
+ await queryRunner.query(`
+ INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at)
+ SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now()
+ FROM freight.bookings b
+ JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME'
+ WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL
+ ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Unlink bookings and drop the synthesised contracts (and their cascaded routes).
+ await queryRunner.query(`
+ UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL
+ WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%');
+ `);
+ await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts
new file mode 100644
index 000000000..1a198e983
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts
@@ -0,0 +1,95 @@
+import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
+
+export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
+ name = 'CreateImportOperationsTables1823000000000';
+
+ async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'djibouti_import_incidents',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
+ { name: 'booking_id', type: 'uuid' },
+ { name: 'container_number', type: 'varchar', length: '80', isNullable: true },
+ { name: 'cargo_id', type: 'uuid', isNullable: true },
+ { name: 'facility', type: 'varchar', length: '120', isNullable: true },
+ { name: 'station', type: 'varchar', length: '120', isNullable: true },
+ { name: 'incident_type', type: 'varchar', length: '40' },
+ { name: 'description', type: 'text' },
+ { name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
+ { name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
+ { name: 'reported_at', type: 'timestamptz' },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+ await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
+ await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
+ await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
+
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'import_customs_finalizations',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
+ { name: 'booking_id', type: 'uuid', isUnique: true },
+ { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
+ { name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
+ { name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
+ { name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
+ { name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
+ { name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
+ { name: 'completed_at', type: 'timestamptz', isNullable: true },
+ { name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
+ { name: 'notes', type: 'text', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+ await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
+ await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
+
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'empty_container_returns',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
+ { name: 'container_number', type: 'varchar', length: '80' },
+ { name: 'booking_id', type: 'uuid', isNullable: true },
+ { name: 'customer_id', type: 'uuid', isNullable: true },
+ { name: 'return_date', type: 'timestamptz' },
+ { name: 'facility', type: 'varchar', length: '120', isNullable: true },
+ { name: 'yard', type: 'varchar', length: '120', isNullable: true },
+ { name: 'zone', type: 'varchar', length: '120', isNullable: true },
+ { name: 'condition', type: 'text', isNullable: true },
+ { name: 'handover_note', type: 'text', isNullable: true },
+ { name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
+ { name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
+ { name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+ await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
+ await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
+ await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
+ }
+
+ async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.empty_container_returns', true);
+ await queryRunner.dropTable('freight.import_customs_finalizations', true);
+ await queryRunner.dropTable('freight.djibouti_import_incidents', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts b/apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts
new file mode 100644
index 000000000..06bcff14a
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts
@@ -0,0 +1,41 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Cutover cleanup (docs/new-doc.md Β§17 Phase 4). Runs AFTER the backfill
+ * (1823β¦) so every legacy general contract + drawdown already lives in the
+ * `contracts` aggregate.
+ *
+ * Drops the now-unused booking-as-contract artifacts:
+ * - `bookings.booking_type` (every booking is a real shipment now)
+ * - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`)
+ * - the `booking_orders` / `booking_order_lines` drawdown ledger
+ * - `contract_route_lines` (superseded by `contract_routes`)
+ *
+ * The shipment/payment/scheduling/allocation columns on `bookings` are kept β
+ * the operational pipeline is unchanged.
+ */
+export class DropLegacyContractTables1824000000000 implements MigrationInterface {
+ name = 'DropLegacyContractTables1824000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // booking_order_lines references booking_orders β drop child first.
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`);
+
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`);
+ await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Re-add the dropped columns (data is not restored β this is a one-way cutover).
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`,
+ );
+ // The legacy ledger/route tables are intentionally NOT recreated here; restore
+ // from a backup if a rollback past the cutover is ever required.
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts b/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts
new file mode 100644
index 000000000..cf0cc51f4
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts
@@ -0,0 +1,65 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Global Logistics Phase-2 operational features (docs/new-doc.md Β§11βΒ§13, gap
+ * matrix #14/#16/#17/#18):
+ * - `clearance_milestones.metadata` β structured payload for RISK_ASSIGNED
+ * (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial)
+ * - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at`
+ * β station routing + staff binding (GL US-02)
+ * - `freight.clearance_incidents` β cargo exception/damage reports with photos
+ * (GL Import US-07)
+ */
+export class AddGlOperations1825000000000 implements MigrationInterface {
+ name = 'AddGlOperations1825000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`,
+ );
+
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`,
+ );
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.clearance_incidents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
+ incident_type VARCHAR(32) NOT NULL,
+ description TEXT NOT NULL,
+ photo_file_ids JSONB NOT NULL DEFAULT '[]',
+ reported_by_user_id UUID,
+ reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+ );
+ `);
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS idx_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`);
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts
new file mode 100644
index 000000000..70b0832f5
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts
@@ -0,0 +1,80 @@
+import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
+
+/**
+ * Create the freight.booking_container_allocations table β container-to-vehicle
+ * allocation mapping for flexible routing of containers across available vehicles.
+ */
+export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface {
+ name = 'CreateBookingContainerAllocations1825000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ const exists = await queryRunner.hasTable('freight.booking_container_allocations');
+ if (exists) return;
+
+ await queryRunner.createTable(
+ new Table({
+ name: 'freight.booking_container_allocations',
+ columns: [
+ {
+ name: 'id',
+ type: 'uuid',
+ isPrimary: true,
+ default: 'gen_random_uuid()',
+ },
+ { name: 'booking_id', type: 'uuid', isNullable: false },
+ { name: 'container_id', type: 'uuid', isNullable: false },
+ { name: 'vehicle_id', type: 'uuid', isNullable: true },
+ {
+ name: 'container_type',
+ type: 'text',
+ isNullable: false,
+ },
+ {
+ name: 'quantity',
+ type: 'integer',
+ default: 1,
+ isNullable: false,
+ },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.booking_container_allocations',
+ new TableForeignKey({
+ columnNames: ['booking_id'],
+ referencedTableName: 'freight.bookings',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ }),
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.booking_container_allocations',
+ new TableForeignKey({
+ columnNames: ['vehicle_id'],
+ referencedTableName: 'freight.vehicles',
+ referencedColumnNames: ['id'],
+ onDelete: 'SET NULL',
+ }),
+ );
+
+ await queryRunner.query(
+ `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ const exists = await queryRunner.hasTable('freight.booking_container_allocations');
+ if (exists) {
+ await queryRunner.dropTable('freight.booking_container_allocations');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1826000000000-AddCargoScopeQuantityCap.ts b/apps/edr-freight-api/src/migrations/1826000000000-AddCargoScopeQuantityCap.ts
new file mode 100644
index 000000000..97428c11b
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1826000000000-AddCargoScopeQuantityCap.ts
@@ -0,0 +1,23 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * GENERAL contracts can be booked repeatedly until a total cargo quantity cap is
+ * reached (e.g. 100 containers across many shipments). `quantity_cap` on each
+ * cargo-scope line holds that ceiling (containers per size, or tons/items for
+ * bulk). NULL = uncapped; always NULL for ONE_TIME (single booking).
+ */
+export class AddCargoScopeQuantityCap1826000000000 implements MigrationInterface {
+ name = 'AddCargoScopeQuantityCap1826000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.contract_cargo_scope ADD COLUMN IF NOT EXISTS quantity_cap NUMERIC(12,2);`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.contract_cargo_scope DROP COLUMN IF EXISTS quantity_cap;`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts b/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts
new file mode 100644
index 000000000..e88824e28
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts
@@ -0,0 +1,75 @@
+import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
+
+/**
+ * Customer shipment requests for GENERAL customs (Path B) contracts. The customer
+ * submits date + quantities; Global Logistics reviews, then creates the booking
+ * on their behalf and per-booking clearance begins. Additive β no change to
+ * existing tables; ONE_TIME contracts are unaffected.
+ */
+export class CreateBookingRequests1827000000000 implements MigrationInterface {
+ name = 'CreateBookingRequests1827000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.createTable(
+ new Table({
+ schema: 'freight',
+ name: 'booking_requests',
+ columns: [
+ { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
+ { name: 'reference', type: 'varchar', length: '40', default: "''" },
+ { name: 'contract_id', type: 'uuid' },
+ { name: 'requested_by_user_id', type: 'uuid', isNullable: true },
+ { name: 'contract_route_id', type: 'uuid', isNullable: true },
+ { name: 'scheduled_date', type: 'timestamptz', isNullable: true },
+ { name: 'status', type: 'varchar', length: '16', default: "'PENDING'" },
+ { name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" },
+ { name: 'notes', type: 'text', isNullable: true },
+ { name: 'created_booking_id', type: 'uuid', isNullable: true },
+ { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
+ { name: 'reviewed_at', type: 'timestamptz', isNullable: true },
+ { name: 'review_note', type: 'text', isNullable: true },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ foreignKeys: [
+ {
+ columnNames: ['contract_id'],
+ referencedSchema: 'freight',
+ referencedTableName: 'contracts',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ },
+ {
+ columnNames: ['created_booking_id'],
+ referencedSchema: 'freight',
+ referencedTableName: 'bookings',
+ referencedColumnNames: ['id'],
+ onDelete: 'SET NULL',
+ },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createIndex(
+ 'freight.booking_requests',
+ new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }),
+ );
+ await queryRunner.createIndex(
+ 'freight.booking_requests',
+ new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }),
+ );
+ await queryRunner.createIndex(
+ 'freight.booking_requests',
+ new TableIndex({
+ name: 'idx_booking_requests_contract_status',
+ columnNames: ['contract_id', 'status'],
+ }),
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.dropTable('freight.booking_requests', true);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts
new file mode 100644
index 000000000..c57a43aaa
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts
@@ -0,0 +1,34 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface {
+ name = 'AddGrnNumberToWarehouseInventory1828000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.warehouse_inventory
+ ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.warehouse_inventory
+ SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
+ WHERE grn_number IS NULL
+ AND notes IS NOT NULL
+ AND notes ~ 'GRN Number: '
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
+ ON freight.warehouse_inventory(grn_number)
+ WHERE grn_number IS NOT NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`);
+ await queryRunner.query(`
+ ALTER TABLE freight.warehouse_inventory
+ DROP COLUMN IF EXISTS grn_number
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts
new file mode 100644
index 000000000..b91e88633
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts
@@ -0,0 +1,74 @@
+import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
+
+/**
+ * Create freight.first_mile_container_allocations table β tracks
+ * container allocations per first-mile shipment with optional vehicle assignment.
+ */
+export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
+ if (exists) return;
+
+ await queryRunner.createTable(
+ new Table({
+ name: 'freight.first_mile_container_allocations',
+ columns: [
+ {
+ name: 'id',
+ type: 'uuid',
+ isPrimary: true,
+ default: 'gen_random_uuid()',
+ },
+ { name: 'first_mile_id', type: 'uuid', isNullable: false },
+ { name: 'container_id', type: 'uuid', isNullable: false },
+ { name: 'vehicle_id', type: 'uuid', isNullable: true },
+ { name: 'container_type', type: 'text', isNullable: false },
+ {
+ name: 'quantity',
+ type: 'int',
+ default: 1,
+ isNullable: false,
+ },
+ { name: 'created_at', type: 'timestamptz', default: 'now()' },
+ { name: 'updated_at', type: 'timestamptz', default: 'now()' },
+ { name: 'deleted_at', type: 'timestamptz', isNullable: true },
+ ],
+ }),
+ true,
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.first_mile_container_allocations',
+ new TableForeignKey({
+ columnNames: ['first_mile_id'],
+ referencedTableName: 'freight.first_mile',
+ referencedColumnNames: ['id'],
+ onDelete: 'CASCADE',
+ }),
+ );
+
+ await queryRunner.createForeignKey(
+ 'freight.first_mile_container_allocations',
+ new TableForeignKey({
+ columnNames: ['vehicle_id'],
+ referencedTableName: 'freight.vehicles',
+ referencedColumnNames: ['id'],
+ onDelete: 'SET NULL',
+ }),
+ );
+
+ await queryRunner.query(
+ `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ const exists = await queryRunner.hasTable('freight.first_mile_container_allocations');
+ if (exists) {
+ await queryRunner.dropTable('freight.first_mile_container_allocations');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts
index 5a801cf73..e954b7e1b 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
@Controller("billing")
@FreightAdmin()
export class BillingController {
- constructor(private readonly billingService: BillingService) {}
+ constructor(private readonly billingService: BillingService) { }
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
@@ -16,9 +16,9 @@ export class BillingController {
return this.billingService.findAll();
}
- @Get("invoices/booking/:bookingId")
- @ApiOperation({ summary: "List invoices for a booking" })
- findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
- return this.billingService.findByBooking(bookingId);
+ @Get("invoices/:id")
+ @ApiOperation({ summary: "Get an invoice with its line items" })
+ findById(@Param("id", ParseUUIDPipe) id: string) {
+ return this.billingService.findById(id);
}
}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts
index 2b16b1515..551fae6bf 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.module.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts
@@ -1,14 +1,24 @@
-import { Module } from "@nestjs/common";
+import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
+import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { Invoice } from "./entities/invoice.entity";
+import { InvoiceLine } from "./entities/invoice-line.entity";
+import { InvoiceRepository } from "./invoice.repository";
+import { InvoiceLineRepository } from "./invoice-line.repository";
+import { PaymentModule } from "../payment/payment.module";
+import { CompaniesModule } from "../companies/companies.module";
@Module({
- imports: [TypeOrmModule.forFeature([Invoice])],
- controllers: [BillingController],
- providers: [BillingService],
+ imports: [
+ TypeOrmModule.forFeature([Invoice, InvoiceLine]),
+ forwardRef(() => PaymentModule),
+ CompaniesModule,
+ ],
+ controllers: [BillingController, PortalBillingController],
+ providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})
export class BillingModule {}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
new file mode 100644
index 000000000..0e6d97de0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
@@ -0,0 +1,250 @@
+import { Freight } from "@edr/types";
+
+import { BillingService } from "./billing.service";
+
+/**
+ * Minimal in-memory EntityManager stand-in covering the methods
+ * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
+ */
+function makeManager(savedLines: unknown[]) {
+ return {
+ create: (_entity: unknown, data: Record) => data,
+ save: (data: Record) => {
+ const row = { id: data.id ?? "gen-1", ...data };
+ if (data.invoiceId) savedLines.push(row);
+ return Promise.resolve(row);
+ },
+ query: () => Promise.resolve([{ seq: 0 }]),
+ update: jest.fn().mockResolvedValue(undefined),
+ findOne: jest.fn().mockResolvedValue(null),
+ };
+}
+
+function makeEvents() {
+ return { emit: jest.fn() };
+}
+
+function generateInput(overrides: Record = {}) {
+ return {
+ source: Freight.InvoiceSource.Booking,
+ sourceId: "booking-1",
+ type: "prepaid",
+ companyId: "company-1",
+ companyProfileId: "profile-1",
+ currency: "ETB",
+ lines: [
+ {
+ chargeType: "RAIL_FREIGHT",
+ description: "Rail freight",
+ quantity: 2,
+ unitRate: 500,
+ amount: 1000,
+ },
+ {
+ chargeType: "HAZARD_SURCHARGE",
+ description: "Hazard surcharge",
+ quantity: 2,
+ unitRate: 250,
+ amount: 500,
+ },
+ ],
+ ...overrides,
+ };
+}
+
+describe("BillingService.generateInvoice", () => {
+ let savedLines: unknown[];
+ let manager: ReturnType;
+ let events: ReturnType;
+ let dataSource: { transaction: jest.Mock; manager: unknown };
+ let service: BillingService;
+
+ beforeEach(() => {
+ savedLines = [];
+ manager = makeManager(savedLines);
+ events = makeEvents();
+ dataSource = {
+ transaction: jest
+ .fn()
+ .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
+ manager,
+ };
+ service = new BillingService(
+ dataSource as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+ });
+
+ it("creates a PENDING invoice with one line per input line", async () => {
+ const invoice = await service.generateInvoice(generateInput());
+
+ expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
+ expect(invoice.companyId).toBe("company-1");
+ expect(invoice.source).toBe("booking");
+ expect(invoice.sourceId).toBe("booking-1");
+ expect(invoice.totalAmount).toBe(1500);
+ expect(invoice.issuedAt).toBeInstanceOf(Date);
+ expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
+ expect(savedLines).toHaveLength(2);
+ });
+
+ it("sums line amounts when no explicit totalAmount is given", async () => {
+ const invoice = await service.generateInvoice(
+ generateInput({ totalAmount: undefined }),
+ );
+ expect(invoice.totalAmount).toBe(1500);
+ });
+
+ it("leaves issuedAt null for a DRAFT invoice", async () => {
+ const invoice = await service.generateInvoice(
+ generateInput({ status: Freight.InvoiceStatus.Draft }),
+ );
+ expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
+ expect(invoice.issuedAt).toBeNull();
+ });
+
+ it("enlists in a caller's transaction when a manager is passed", async () => {
+ await service.generateInvoice(generateInput(), manager as never);
+ expect(dataSource.transaction).not.toHaveBeenCalled();
+ expect(savedLines).toHaveLength(2);
+ });
+});
+
+describe("BillingService.markInvoiceAsPaid", () => {
+ it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
+ const open = {
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Pending,
+ source: "booking",
+ sourceId: "booking-1",
+ };
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(open),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
+
+ expect(mg.update).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: "inv-1" },
+ { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
+ );
+ expect(events.emit).toHaveBeenCalledWith(
+ "booking.invoice.paid",
+ expect.objectContaining({
+ invoiceId: "inv-1",
+ status: Freight.InvoiceStatus.Paid,
+ paymentId: "pay-1",
+ }),
+ );
+ });
+
+ it("is a no-op (no event) when the invoice is already paid", async () => {
+ const paid = {
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Paid,
+ source: "booking",
+ };
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(paid),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
+
+ expect(mg.update).not.toHaveBeenCalled();
+ expect(events.emit).not.toHaveBeenCalled();
+ });
+});
+
+describe("BillingService.settlePayable", () => {
+ it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
+ const open = {
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Pending,
+ source: Freight.InvoiceSource.Booking,
+ sourceId: "booking-1",
+ };
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(open),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ const settled = await service.settlePayable(
+ Freight.InvoiceSource.Booking,
+ "booking-1",
+ "pay-1",
+ mg as never,
+ );
+
+ expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
+ expect(mg.update).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: "inv-1" },
+ { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
+ );
+ expect(events.emit).toHaveBeenCalledWith(
+ "booking.invoice.paid",
+ expect.anything(),
+ );
+ });
+
+ it("is a no-op (returns null) when the source has no open invoice", async () => {
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(null),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ const settled = await service.settlePayable(
+ Freight.InvoiceSource.Booking,
+ "booking-1",
+ "pay-1",
+ mg as never,
+ );
+
+ expect(settled).toBeNull();
+ expect(mg.update).not.toHaveBeenCalled();
+ expect(events.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 39eae6ef5..01b057a76 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -1,26 +1,542 @@
-import { Injectable } from "@nestjs/common";
-import { InjectRepository } from "@nestjs/typeorm";
-import { Repository } from "typeorm";
+import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import { Freight, PaymentReferenceType } from "@edr/types";
+import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
+import { InvoiceLine } from "./entities/invoice-line.entity";
+import { InvoiceRepository } from "./invoice.repository";
+import { InvoiceLineRepository } from "./invoice-line.repository";
+import { PaymentService } from "../payment/payment.service";
+import { InitiateResponseDto } from "../payment/payments.dto";
+import { CompaniesService } from "../companies/companies.service";
+
+/** Options forwarded to the payment gateway when settling an invoice. */
+export interface PayInvoiceOptions {
+ method?: string;
+ platform?: "web" | "mobile";
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+}
+
+/** Default invoice payment-term window, in days, used to compute `dueAt`. */
+const DEFAULT_DUE_DAYS = 14;
+
+/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
+const OPEN_STATUSES: Freight.InvoiceStatus[] = [
+ Freight.InvoiceStatus.Draft,
+ Freight.InvoiceStatus.Pending,
+ Freight.InvoiceStatus.Overdue,
+];
+
+/** A single line to bill on a generated invoice. */
+export interface InvoiceLineInput {
+ chargeType: string;
+ description?: string;
+ /** Units this line bills for; defaults to 1. */
+ quantity?: number;
+ /** Price per unit; defaults to 0. */
+ unitRate?: number;
+ /** Line total; defaults to `quantity * unitRate`. */
+ amount?: number;
+ currency?: string;
+ metadata?: Record | null;
+}
+
+/** Everything needed to generate an invoice for any source. */
+export interface GenerateInvoiceInput {
+ /** Originating subsystem; namespaces events (`${source}.invoice.`). */
+ source: Freight.InvoiceSource;
+ /** Identifier of the source record (e.g. booking id). */
+ sourceId: string;
+ /** What the invoice is for (e.g. "prepaid", "credit"). */
+ type: string;
+ companyId: string;
+ companyProfileId: string;
+ lines: InvoiceLineInput[];
+ currency?: string;
+ /** Explicit total; defaults to the sum of line amounts. */
+ totalAmount?: number;
+ /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
+ dueAt?: Date;
+ dueInDays?: number;
+ /**
+ * Initial status. DRAFT leaves `issuedAt` null; any issued status
+ * (default PENDING) stamps `issuedAt`.
+ */
+ status?: Freight.InvoiceStatus;
+}
+
+/** Payload broadcast on `${source}.invoice.`. */
+export interface InvoiceEventPayload {
+ invoiceId: string;
+ invoiceNumber: string;
+ source: Freight.InvoiceSource;
+ sourceId: string;
+ type: string;
+ companyId: string;
+ companyProfileId: string;
+ totalAmount: number;
+ currency: string;
+ status: Freight.InvoiceStatus;
+ paymentId?: string | null;
+}
@Injectable()
export class BillingService {
+ private readonly logger = new Logger(BillingService.name);
+
constructor(
- @InjectRepository(Invoice)
- private readonly invoicesRepository: Repository,
- ) {}
+ private readonly dataSource: DataSource,
+ private readonly invoices: InvoiceRepository,
+ private readonly invoiceLines: InvoiceLineRepository,
+ private readonly events: EventEmitter2,
+ @Inject(forwardRef(() => PaymentService))
+ private readonly payment: PaymentService,
+ private readonly companies: CompaniesService,
+ ) { }
+
+ // ββ Reads ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** List every invoice (most recent first). */
findAll(): Promise {
- return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
+ return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
- /** List invoices for a given booking. */
- findByBooking(bookingId: string): Promise {
- return this.invoicesRepository.find({
- where: { bookingId },
+ /** Invoice header plus its line items. */
+ async findById(id: string): Promise {
+ const invoice = await this.invoices.findById(id);
+ if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
+ const lines = await this.invoiceLines.findAll({
+ where: { invoiceId: id },
+ order: { createdAt: "ASC" },
+ });
+ return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
+ }
+
+ // ββ Customer-scoped reads (portal) βββββββββββββββββββββββββββββββββββββββββββ
+
+ /** Resolve the customer's company id from their IAM user id (null if none). */
+ async resolveCompanyId(userId: string): Promise {
+ try {
+ const { company } = await this.companies.getCompanyInfoByUserId(userId);
+ return company?.id ?? null;
+ } catch {
+ return null;
+ }
+ }
+
+ /** Every invoice billed to a company, newest first, with billing relations. */
+ findByCompany(companyId: string): Promise {
+ return this.invoices.findAll({
+ where: { companyId },
+ relations: { company: true, companyProfile: true },
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ /** Invoices for the signed-in customer; empty when they have no company. */
+ async findForUser(userId: string): Promise {
+ const companyId = await this.resolveCompanyId(userId);
+ return companyId ? this.findByCompany(companyId) : [];
+ }
+
+ /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
+ async findByIdForUser(
+ id: string,
+ userId: string,
+ ): Promise {
+ const companyId = await this.resolveCompanyId(userId);
+ const invoice = await this.findById(id);
+ if (!companyId || invoice.companyId !== companyId) {
+ throw new NotFoundException(`Invoice ${id} not found`);
+ }
+ return invoice;
+ }
+
+ /**
+ * Initiate gateway payment for one of the customer's own invoices. Verifies
+ * ownership, then charges whichever open invoice the source currently has
+ * (see {@link payInvoice}).
+ */
+ async payInvoiceForUser(
+ id: string,
+ userId: string,
+ opts: PayInvoiceOptions = {},
+ ): Promise {
+ const invoice = await this.findByIdForUser(id, userId);
+ return this.payInvoice(
+ invoice.source as Freight.InvoiceSource,
+ invoice.sourceId,
+ opts,
+ );
+ }
+
+ // ββ Generation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /** `FRT-YYYYMMDD-00001` β sequential per day, within the active transaction. */
+ private async nextInvoiceNumber(mg: EntityManager): Promise {
+ const now = new Date();
+ const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
+ const prefix = `FRT-${ymd}-`;
+ const [row] = await mg.query(
+ `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
+ FROM freight.invoices WHERE invoice_number LIKE $1`,
+ [`${prefix}%`],
+ );
+ const next = Number(row?.seq ?? 0) + 1;
+ return `${prefix}${String(next).padStart(5, "0")}`;
+ }
+
+ /**
+ * Generate an invoice for any source (booking, demurrage, manual, β¦).
+ *
+ * Persists the header plus its lines in one transaction and assigns the next
+ * sequential `invoice_number`. The total defaults to the sum of line amounts
+ * unless `totalAmount` is given. Issued invoices (default PENDING) stamp
+ * `issuedAt`; pass `status: DRAFT` to leave it unissued.
+ *
+ * Pass `manager` to enlist in a caller's transaction (e.g. when generating an
+ * invoice as part of a larger booking flow).
+ */
+ async generateInvoice(
+ input: GenerateInvoiceInput,
+ manager?: EntityManager,
+ ): Promise {
+ const run = (mg: EntityManager) => this.createInvoice(input, mg);
+ return manager ? run(manager) : this.dataSource.transaction(run);
+ }
+
+ private async createInvoice(
+ input: GenerateInvoiceInput,
+ mg: EntityManager,
+ ): Promise {
+ const currency = input.currency ?? "ETB";
+ const status = input.status ?? Freight.InvoiceStatus.Pending;
+ const issued = status !== Freight.InvoiceStatus.Draft;
+
+ const lines = input.lines.map((l) => {
+ const quantity = l.quantity ?? 1;
+ const unitRate = l.unitRate ?? 0;
+ return {
+ chargeType: l.chargeType,
+ description: l.description,
+ quantity,
+ unitRate,
+ amount: l.amount ?? quantity * unitRate,
+ currency: l.currency ?? currency,
+ metadata: l.metadata ?? null,
+ };
+ });
+
+ const totalAmount =
+ input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
+
+ const dueAt =
+ input.dueAt ??
+ new Date(
+ Date.now() +
+ (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
+ );
+
+ const invoiceNumber = await this.nextInvoiceNumber(mg);
+
+ const invoice = await mg.save(
+ mg.create(Invoice, {
+ invoiceNumber,
+ source: input.source,
+ sourceId: input.sourceId,
+ type: input.type,
+ companyId: input.companyId,
+ companyProfileId: input.companyProfileId,
+ totalAmount,
+ currency,
+ status,
+ issuedAt: issued ? new Date() : null,
+ dueAt,
+ }),
+ );
+
+ const savedLines = await Promise.all(
+ lines.map((l) =>
+ mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
+ ),
+ );
+
+ this.logger.log(
+ `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
+ );
+
+ return { ...invoice, lines: savedLines };
+ }
+
+ // ββ State transitions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Mark an invoice paid and link the gateway payment, then emit
+ * `${source}.invoice.paid`. Full-payment only β no partial settlement.
+ * No-op when the invoice is already paid. Pass `manager` to enlist in a
+ * caller's transaction.
+ */
+ async markInvoiceAsPaid(
+ invoiceId: string,
+ paymentId: string | null = null,
+ manager?: EntityManager,
+ ): Promise {
+ return this.transition(
+ invoiceId,
+ Freight.InvoiceStatus.Paid,
+ "paid",
+ { paymentId: paymentId ?? undefined },
+ manager,
+ );
+ }
+
+ /**
+ * Mark an invoice refunded and emit `${source}.invoice.refunded`.
+ * No-op when already refunded.
+ */
+ async markInvoiceAsRefunded(
+ invoiceId: string,
+ manager?: EntityManager,
+ ): Promise {
+ return this.transition(
+ invoiceId,
+ Freight.InvoiceStatus.Refunded,
+ "refunded",
+ {},
+ manager,
+ );
+ }
+
+ /**
+ * Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
+ * No-op when already cancelled.
+ */
+ async cancelInvoice(
+ invoiceId: string,
+ manager?: EntityManager,
+ ): Promise {
+ return this.transition(
+ invoiceId,
+ Freight.InvoiceStatus.Cancelled,
+ "cancelled",
+ {},
+ manager,
+ );
+ }
+
+ /**
+ * Load the invoice, apply the new status (+ extra columns), then emit
+ * `${source}.invoice.`. No-op (returns the invoice) when it is already
+ * in the target status. Throws when the invoice does not exist.
+ *
+ * Note: the event fires in-process synchronously. When a `manager` from an
+ * outer transaction is passed, listeners run before that transaction commits.
+ */
+ private async transition(
+ invoiceId: string,
+ status: Freight.InvoiceStatus,
+ event: string,
+ extra: { paymentId?: string },
+ manager?: EntityManager,
+ ): Promise {
+ const mg = manager ?? this.dataSource.manager;
+ const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
+ if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
+ if (invoice.status === status) return invoice;
+
+ await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
+
+ const updated = { ...invoice, ...extra, status } as Invoice;
+ this.emitInvoiceEvent(event, updated);
+ return updated;
+ }
+
+ /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */
+ private emitInvoiceEvent(event: string, invoice: Invoice): void {
+ const payload: InvoiceEventPayload = {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoiceNumber,
+ source: invoice.source as Freight.InvoiceSource,
+ sourceId: invoice.sourceId,
+ type: invoice.type,
+ companyId: invoice.companyId,
+ companyProfileId: invoice.companyProfileId,
+ totalAmount: invoice.totalAmount,
+ currency: invoice.currency,
+ status: invoice.status,
+ paymentId: invoice.paymentId ?? null,
+ };
+ this.events.emit(`${invoice.source}.invoice.${event}`, payload);
+ }
+
+ // ββ Payment reconciliation (by source) βββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * The invoice a gateway payment should settle for a source record, or null if
+ * none. This is the billing document of record for "what is owed" β callers
+ * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
+ * recomputing from the source's own total, so discounts/penalties/adjustments
+ * carried on the invoice are honored.
+ *
+ * Pass `type` to select a specific invoice when a source carries several (e.g.
+ * a booking's up-front vs final charge); omit it to settle whichever single
+ * invoice is currently open. Returns the most recent matching open (unpaid,
+ * non-cancelled) invoice.
+ */
+ findPayable(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ type?: string,
+ ): Promise {
+ return this.dataSource.getRepository(Invoice).findOne({
+ where: {
+ source,
+ sourceId,
+ status: In(OPEN_STATUSES),
+ ...(type ? { type } : {}),
+ },
order: { issuedAt: "DESC" },
});
}
+
+ /**
+ * Settle a source's currently-open invoice as paid and link the gateway
+ * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
+ * delegates to {@link markInvoiceAsPaid}. Full-payment only β no partial
+ * settlement. No-op (returns null) when the source has no open invoice.
+ *
+ * Type-blind by design: settles whichever invoice is due; any per-type reaction
+ * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
+ * Pass the caller's transaction `manager` to enlist in its DB transaction.
+ *
+ * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
+ * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
+ * for callers that settle by source rather than by gateway intent id.
+ */
+ async settlePayable(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ paymentId: string | null,
+ manager?: EntityManager,
+ ): Promise {
+ const mg = manager ?? this.dataSource.manager;
+ const invoice = await mg.findOne(Invoice, {
+ where: { source, sourceId, status: In(OPEN_STATUSES) },
+ order: { issuedAt: "DESC" },
+ });
+ if (!invoice) return null;
+
+ return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
+ }
+
+ /**
+ * Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
+ * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
+ * No-op (returns null) when the source has no paid invoice.
+ *
+ * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
+ * to enlist in its DB transaction.
+ */
+ async refundPayable(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ manager?: EntityManager,
+ ): Promise {
+ const mg = manager ?? this.dataSource.manager;
+ const invoice = await mg.findOne(Invoice, {
+ where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
+ order: { issuedAt: "DESC" },
+ });
+ if (!invoice) return null;
+
+ return this.markInvoiceAsRefunded(invoice.id, mg);
+ }
+
+ // ββ Payment initiation & settlement (the gateway boundary) βββββββββββββββββββ
+
+ /**
+ * Charge a source's open invoice through the payment gateway. Billing is the
+ * single place that turns "what is owed" (the invoice) into a payment intent β
+ * the domain never talks to the payment service directly. Resolves the open
+ * invoice, opens an intent for `invoice.totalAmount`, records the intent id on
+ * the invoice (the settlement correlation key), and returns the client action.
+ *
+ * When the provider settles synchronously, the invoice is settled inline here β
+ * after the intent id is stored β so the `payment.succeeded` correlation can
+ * never fire before the link exists. Throws when the source has no open invoice.
+ */
+ async payInvoice(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ opts: {
+ method?: string;
+ platform?: "web" | "mobile";
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+ } = {},
+ ): Promise {
+ const invoice = await this.findPayable(source, sourceId);
+ if (!invoice) {
+ throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
+ }
+
+ const result = await this.payment.initiate({
+ referenceId: sourceId,
+ source: invoice.source,
+ // Gateway reference type derives from the invoice source by convention
+ // (source.toUpperCase() β PaymentReferenceType) β no domain word here, and
+ // the domain never supplies it. New sources add their uppercased value to
+ // the PaymentReferenceType enum.
+ referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
+ orderRef: invoice.invoiceNumber,
+ amountMinor: Math.round(Number(invoice.totalAmount)),
+ currency: invoice.currency,
+ reason: `Payment for invoice ${invoice.invoiceNumber}`,
+ method: opts.method ?? "TELEBIRR",
+ platform: opts.platform,
+ payerAccount: opts.payerAccount,
+ returnUrl: opts.returnUrl,
+ failureUrl: opts.failureUrl,
+ });
+
+ // Link the intent to the invoice BEFORE any settlement can correlate against it.
+ await this.dataSource
+ .getRepository(Invoice)
+ .update({ id: invoice.id }, { paymentId: result.intentId });
+
+ if (result.immediateSuccess) {
+ await this.settleByPaymentId(
+ result.intentId,
+ result.providerTxnId,
+ result.paidAt,
+ );
+ }
+
+ return result.response;
+ }
+
+ /**
+ * Settle the open invoice linked to a gateway intent id, if any. Called by the
+ * payment service when an intent succeeds: finds the invoice linked by
+ * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
+ * to advance on. Idempotent β no-op when no open invoice is linked (already
+ * settled, or settled inline by {@link payInvoice}).
+ */
+ async settleByPaymentId(
+ paymentId: string,
+ _providerTxnId?: string,
+ _paidAt?: Date,
+ ): Promise {
+ const invoice = await this.dataSource.getRepository(Invoice).findOne({
+ where: { paymentId, status: In(OPEN_STATUSES) },
+ order: { issuedAt: "DESC" },
+ });
+ if (!invoice) return null;
+
+ return this.markInvoiceAsPaid(invoice.id, paymentId);
+ }
}
diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts
new file mode 100644
index 000000000..c29160ab7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts
@@ -0,0 +1,30 @@
+import { ApiPropertyOptional } from "@nestjs/swagger";
+import { IsIn, IsOptional, IsString } from "class-validator";
+
+/** Gateway options for paying an invoice from the customer portal. */
+export class PayInvoiceDto {
+ @ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
+ @IsOptional()
+ @IsString()
+ method?: string;
+
+ @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
+ @IsOptional()
+ @IsIn(["web", "mobile"])
+ platform?: "web" | "mobile";
+
+ @ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
+ @IsOptional()
+ @IsString()
+ payerAccount?: string;
+
+ @ApiPropertyOptional({ description: "Browser redirect URL on success." })
+ @IsOptional()
+ @IsString()
+ returnUrl?: string;
+
+ @ApiPropertyOptional({ description: "Browser redirect URL on failure." })
+ @IsOptional()
+ @IsString()
+ failureUrl?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts
new file mode 100644
index 000000000..a042dedb7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts
@@ -0,0 +1,43 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
+
+import { Invoice } from "./invoice.entity";
+
+@Entity({ schema: "freight", name: "invoice_lines" })
+export class InvoiceLine extends BaseEntity {
+ @Column({ name: "invoice_id", type: "uuid", nullable: false })
+ invoiceId!: string;
+
+ @ManyToOne(() => Invoice, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "invoice_id" })
+ invoice!: Invoice;
+
+ @Column({ name: "charge_type", type: "varchar", nullable: false })
+ chargeType!: string;
+
+ @Column({ name: "description", type: "varchar", length: 255, nullable: true })
+ description?: string;
+
+ /** Units this line bills for (e.g. container count, wagon count, tons). */
+ @Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
+ quantity!: number;
+
+ /** Price per unit; `amount` is normally `quantity * unitRate`. */
+ @Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
+ unitRate!: number;
+
+ @Column({
+ name: "amount",
+ type: "numeric",
+ precision: 14,
+ scale: 2,
+ nullable: false,
+ })
+ amount!: number;
+
+ @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
+ currency!: string;
+
+ @Column({ name: "metadata", type: "jsonb", nullable: true })
+ metadata?: Record | null;
+}
diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts
index e2a6f7cc2..61bc9c16b 100644
--- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts
+++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts
@@ -1,17 +1,35 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
-import { Column, Entity } from "typeorm";
+import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
+import { PaymentEntity } from "../../payment/entities/payment.entity";
+import { Company } from "../../companies/entities/company.entity";
+import { CompanyProfile } from "../../companies/entities/company-profile.entity";
-@Entity({schema:"freight", name: "invoices" })
+@Entity({ schema: "freight", name: "invoices" })
+@Index(["companyId"])
+@Index(["companyProfileId"])
export class Invoice extends BaseEntity {
- @Column({ name: "booking_id", type: "uuid" })
- bookingId!: string;
-
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
- @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
- amount!: number;
+ /** The customer (company) this invoice is billed to. */
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ @ManyToOne(() => Company)
+ @JoinColumn({ name: "company_id" })
+ company?: Company;
+
+ /** The specific company profile (importer/exporter/forwarder/...) billed. */
+ @Column({ name: "company_profile_id", type: "uuid" })
+ companyProfileId!: string;
+
+ @ManyToOne(() => CompanyProfile)
+ @JoinColumn({ name: "company_profile_id" })
+ companyProfile?: CompanyProfile;
+
+ @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
+ totalAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
@Column({
name: "status",
type: "enum",
- enum: Freight.PaymentStatus,
- default: Freight.PaymentStatus.Pending,
+ enum: Freight.InvoiceStatus,
+ default: Freight.InvoiceStatus.Draft,
})
- status!: Freight.PaymentStatus;
+ status!: Freight.InvoiceStatus;
- @Column({ name: "issued_at", type: "timestamptz" })
- issuedAt!: Date;
+ /** The source of the payment (e.g. booking, customer, etc.). */
+ @Column({ name: "source", type: "varchar", length: 255, nullable: false })
+ source!: string;
+
+ /** The ID of the source (e.g. booking ID, customer ID, etc.). */
+ @Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
+ sourceId!: string;
+
+ /** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
+ @Column({
+ type: "varchar",
+ length: 255,
+ nullable: false,
+ })
+ type!: string;
+
+ /** Set when the invoice is actually issued (DRAFT invoices leave this null). */
+ @Column({ name: "issued_at", type: "timestamptz", nullable: true })
+ issuedAt?: Date | null;
+
+ /** The ID of the payment that generated this invoice. */
+ @Column({ name: "payment_id", type: "uuid", nullable: true })
+ paymentId?: string | null;
+
+ @ManyToOne(() => PaymentEntity)
+ @JoinColumn({ name: "payment_id" })
+ payment?: PaymentEntity;
@Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date;
diff --git a/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts
new file mode 100644
index 000000000..6a5482543
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from "@edr/api-common";
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { InvoiceLine } from "./entities/invoice-line.entity";
+
+@Injectable()
+export class InvoiceLineRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(InvoiceLine) repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/invoice.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts
new file mode 100644
index 000000000..cc2e89df7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from "@edr/api-common";
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { Invoice } from "./entities/invoice.entity";
+
+@Injectable()
+export class InvoiceRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Invoice) repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts
new file mode 100644
index 000000000..5a007c320
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts
@@ -0,0 +1,60 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+} from "@nestjs/common";
+import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
+import { CurrentUser } from "@edr/api-common";
+
+import {
+ type AuthUserPayload,
+ resolveAuthUserId,
+} from "../../common/resolve-auth-user-id";
+import { BillingService } from "./billing.service";
+import { PayInvoiceDto } from "./dto/pay-invoice.dto";
+
+/**
+ * Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
+ * org-wide), every route here is force-scoped to the signed-in customer's
+ * company β they only ever see and pay their own invoices.
+ */
+@ApiTags("billing")
+@ApiBearerAuth()
+@Controller("billing")
+export class PortalBillingController {
+ constructor(private readonly billingService: BillingService) {}
+
+ @Get("my-invoices")
+ @ApiOperation({ summary: "List the signed-in customer's invoices" })
+ findMine(@CurrentUser() user: AuthUserPayload) {
+ return this.billingService.findForUser(resolveAuthUserId(user));
+ }
+
+ @Get("my-invoices/:id")
+ @ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
+ findMineById(
+ @Param("id", ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
+ }
+
+ @Post("my-invoices/:id/pay")
+ @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
+ pay(
+ @Param("id", ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ @Body() dto: PayInvoiceDto,
+ ) {
+ return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
+ method: dto.method,
+ platform: dto.platform ?? "web",
+ payerAccount: dto.payerAccount,
+ returnUrl: dto.returnUrl,
+ failureUrl: dto.failureUrl,
+ });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts
deleted file mode 100644
index b05895407..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import {
- Body,
- Controller,
- Get,
- Param,
- ParseUUIDPipe,
- Post,
- Query,
-} from '@nestjs/common';
-import { CurrentUser } from '@edr/api-common';
-import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
-import { ApiOperation, ApiTags } from '@nestjs/swagger';
-import { BookingOrdersService } from './booking-orders.service';
-import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
-import { GeneralContractService } from './general-contract.service';
-
-@ApiTags('Booking Orders')
-@Controller('booking-orders')
-export class BookingOrdersController {
- constructor(
- private readonly ordersService: BookingOrdersService,
- private readonly generalContractService: GeneralContractService,
- ) {}
-
- @Post()
- @ApiOperation({ summary: 'Place a drawdown order against a general contract' })
- async create(
- @Body() dto: CreateBookingOrderDto,
- @CurrentUser() user: TCurrentUser,
- ) {
- return this.ordersService.create(dto, user?.id);
- }
-
- @Get()
- @ApiOperation({ summary: 'List orders placed against a contract' })
- async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
- return this.ordersService.listByContract(contractBookingId);
- }
-
- @Get('contract/:id/pool')
- @ApiOperation({
- summary: 'Contracted / ordered / remaining quantities for a general contract',
- })
- async pool(@Param('id', ParseUUIDPipe) id: string) {
- return this.generalContractService.getQuantityLines(id);
- }
-
- @Get('contract/:id/routes')
- @ApiOperation({
- summary:
- 'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
- })
- async routes(@Param('id', ParseUUIDPipe) id: string) {
- return this.generalContractService.getRouteLines(id);
- }
-
- @Get(':id')
- @ApiOperation({ summary: 'Get a single booking order' })
- async findOne(@Param('id', ParseUUIDPipe) id: string) {
- return this.ordersService.findById(id);
- }
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts
deleted file mode 100644
index aafb04474..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { forwardRef, Module } from '@nestjs/common';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { BookingsModule } from '../bookings/bookings.module';
-import { CompaniesModule } from '../companies/companies.module';
-import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
-import { RuleEngineModule } from '../rule-engine/rule-engine.module';
-import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
-import { BookingOrdersController } from './booking-orders.controller';
-import { BookingOrdersRepository } from './booking-orders.repository';
-import { BookingOrdersService } from './booking-orders.service';
-import { BookingOrder } from './entities/booking-order.entity';
-import { BookingOrderLine } from './entities/booking-order-line.entity';
-import { ContractRouteLine } from './entities/contract-route-line.entity';
-import { GeneralContractService } from './general-contract.service';
-
-@Module({
- imports: [
- TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
- BookingsModule,
- CompaniesModule,
- DropdownSettingsModule,
- RuleEngineModule,
- forwardRef(() => TrainSchedulingModule),
- ],
- controllers: [BookingOrdersController],
- providers: [
- BookingOrdersService,
- BookingOrdersRepository,
- GeneralContractService,
- ],
- exports: [BookingOrdersService, GeneralContractService],
-})
-export class BookingOrdersModule {}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts
deleted file mode 100644
index c45029b2b..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { BaseRepository } from '@edr/api-common';
-import { Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-import { BookingOrder } from './entities/booking-order.entity';
-
-@Injectable()
-export class BookingOrdersRepository extends BaseRepository {
- constructor(
- @InjectRepository(BookingOrder)
- repository: Repository,
- ) {
- super(repository);
- }
-
- /** Orders placed against a given contract, newest first, with their lines. */
- findByContract(contractBookingId: string): Promise {
- return this.repository.find({
- where: { contractBookingId },
- relations: { lines: { containerType: true }, booking: true },
- order: { createdAt: 'DESC' },
- });
- }
-
- override findById(id: string): Promise {
- return this.repository.findOne({
- where: { id },
- relations: { lines: { containerType: true }, booking: true, contractBooking: true },
- });
- }
-
- /** Count this calendar year's orders, for reference generation. */
- async countByYear(year: number): Promise {
- const start = new Date(Date.UTC(year, 0, 1));
- const end = new Date(Date.UTC(year + 1, 0, 1));
- return this.repository
- .createQueryBuilder('o')
- .where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
- .getCount();
- }
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts
deleted file mode 100644
index b870037f4..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { BookingOrdersService } from './booking-orders.service';
-
-/**
- * Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
- * waits for Marketing review (or the customs clearance gate first) β it does
- * NOT auto-enter the train batch pool, and the contract is not charged.
- */
-describe('BookingOrdersService β child spawn on order create', () => {
- function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
- const contract = {
- id: 'c-1',
- bookingType: 'GENERAL_CONTRACT',
- status: 'CONTRACT_ACTIVE',
- expiresAt: new Date('2030-01-01T00:00:00.000Z'),
- freightType: 'BULK',
- originYardId: 'o-1',
- destinationYardId: 'd-1',
- companyId: null,
- paymentCurrency: 'ETB',
- serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
- bookingContainers: [],
- };
-
- // Capture what status the child is created with.
- const created: Record[] = [];
- const managerUpdates: Record[] = [];
- const fakeManager = {
- create: (_entity: unknown, data: Record) => {
- created.push(data);
- return { id: 'child-1', ...data };
- },
- save: async (row: Record) => ({ id: 'child-1', ...row }),
- getRepository: () => ({
- findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
- update: async (_id: string, data: Record) => {
- managerUpdates.push(data);
- },
- }),
- };
-
- const dataSource = {
- transaction: async (cb: (m: unknown) => Promise) => cb(fakeManager),
- getRepository: () => ({ update: jest.fn() }),
- };
- const ordersRepository = {
- countByYear: jest.fn().mockResolvedValue(0),
- findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
- };
- const bookingsRepository = {
- findById: jest.fn().mockResolvedValue(contract),
- countByYear: jest.fn().mockResolvedValue(0),
- };
- const generalContractService = {
- isGeneralContract: () => true,
- getRouteLines: jest.fn().mockResolvedValue([]),
- getQuantityLines: jest
- .fn()
- .mockResolvedValue([
- { containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
- ]),
- isExhausted: jest.fn().mockResolvedValue(false),
- };
- const pricingService = {
- computePriceForBooking: jest.fn().mockResolvedValue({
- totalAmount: 500,
- priorityScore: 10,
- lineItems: [],
- currency: 'ETB',
- }),
- };
- const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
- const trainSchedulingService = {
- existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
- };
- const companiesService = {};
-
- const service = new BookingOrdersService(
- dataSource as never,
- ordersRepository as never,
- bookingsRepository as never,
- companiesService as never,
- generalContractService as never,
- pricingService as never,
- ratesService as never,
- trainSchedulingService as never,
- );
- return { service, created, managerUpdates, pricingService };
- }
-
- const dto = {
- contractBookingId: 'c-1',
- scheduledDate: '2026-07-01T00:00:00.000Z',
- lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
- };
-
- it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
- const { service, created, managerUpdates, pricingService } = makeService({
- includesCustoms: false,
- });
- await service.create(dto as never);
-
- const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
- expect(child.status).toBe('OPERATION_REQUEST_PENDING');
- expect(child.paymentStatus).toBe('PENDING');
- expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
- expect(pricingService.computePriceForBooking).toHaveBeenCalled();
- // The computed price is persisted onto the child.
- expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
- });
-
- it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
- const { service, created } = makeService({ includesCustoms: true });
- await service.create(dto as never);
- const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
- expect(child.status).toBe('AWAITING_DOCUMENTS');
- });
-
- it('rejects when hazardous quantity exceeds the line quantity', async () => {
- const { service } = makeService({ includesCustoms: false });
- await expect(
- service.create({
- ...dto,
- lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
- } as never),
- ).rejects.toThrow(/exceed the line quantity/);
- });
-});
diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts
deleted file mode 100644
index 3c5bc4019..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts
+++ /dev/null
@@ -1,469 +0,0 @@
-import {
- BadRequestException,
- forwardRef,
- Inject,
- Injectable,
- Logger,
- NotFoundException,
-} from '@nestjs/common';
-import { DataSource } from 'typeorm';
-import { BookingsRepository } from '../bookings/bookings.repository';
-import { BookingPricingService } from '../bookings/booking-pricing.service';
-import { clearanceCodesForBooking } from '../bookings/clearance.util';
-import { Booking } from '../bookings/entities/booking.entity';
-import { BookingContainer } from '../bookings/entities/booking-container.entity';
-import { CompaniesService } from '../companies/companies.service';
-import { ContainerType } from '../rule-engine/entities/container-type.entity';
-import { RatesService } from '../rule-engine/services/rates.service';
-import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
-import { eatDay } from '../train-scheduling/batch-window.util';
-import { BookingOrdersRepository } from './booking-orders.repository';
-import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
-import { BookingOrder } from './entities/booking-order.entity';
-import { BookingOrderLine } from './entities/booking-order-line.entity';
-import { GeneralContractService } from './general-contract.service';
-import { isRoadService, roadKmPrice } from './road.util';
-
-@Injectable()
-export class BookingOrdersService {
- private readonly logger = new Logger(BookingOrdersService.name);
-
- constructor(
- private readonly dataSource: DataSource,
- private readonly ordersRepository: BookingOrdersRepository,
- private readonly bookingsRepository: BookingsRepository,
- private readonly companiesService: CompaniesService,
- private readonly generalContractService: GeneralContractService,
- private readonly pricingService: BookingPricingService,
- private readonly ratesService: RatesService,
- @Inject(forwardRef(() => TrainSchedulingService))
- private readonly trainSchedulingService: TrainSchedulingService,
- ) {}
-
- /** Orders placed against a contract, with their lines and child booking. */
- async listByContract(contractBookingId: string): Promise {
- const orders = await this.ordersRepository.findByContract(contractBookingId);
- await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
- return orders;
- }
-
- async findById(id: string): Promise {
- const order = await this.ordersRepository.findById(id);
- if (order) await this.syncOrderFromChild(order);
- return order;
- }
-
- /**
- * The order is a ledger row; the spawned child ONE_TIME booking is what
- * actually moves through the workflow (clearance β marketing/ops accept β
- * pay β allocate), exactly like a one-time booking. Nothing writes the order
- * row after creation, so its stored status would stay 'PENDING' forever.
- *
- * Mirror the child onto the order whenever it is read: copy the child's
- * status, schedulingStatus and trainScheduleId onto the order (mutating the
- * in-memory instance the caller gets back), and persist that snapshot when it
- * has drifted so list/detail views and any stored reporting stay in sync.
- */
- private async syncOrderFromChild(order: BookingOrder): Promise {
- const child = order.booking;
- if (!child) return;
-
- const nextStatus = child.status;
- const nextScheduling = child.schedulingStatus;
- const nextTrainScheduleId = child.trainScheduleId ?? null;
-
- const drifted =
- order.status !== nextStatus ||
- order.schedulingStatus !== nextScheduling ||
- (order.trainScheduleId ?? null) !== nextTrainScheduleId;
-
- // Reflect the child onto the instance returned to the caller.
- order.status = nextStatus;
- order.schedulingStatus = nextScheduling;
- order.trainScheduleId = nextTrainScheduleId;
-
- if (drifted) {
- await this.ordersRepository.update(order.id, {
- status: nextStatus,
- schedulingStatus: nextScheduling,
- trainScheduleId: nextTrainScheduleId,
- });
- }
- }
-
- /**
- * Place a drawdown order against an ACTIVE general contract.
- *
- * Validates the requested quantities against the remaining pool, then spawns a
- * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
- * route/cargo/service) so it flows through the existing train-scheduling
- * pipeline. The order row is the ledger entry linking contract β child booking.
- */
- async create(
- dto: CreateBookingOrderDto,
- userId?: string,
- ): Promise {
- const contract = await this.bookingsRepository.findById(dto.contractBookingId);
- if (!contract) {
- throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
- }
- if (!this.generalContractService.isGeneralContract(contract)) {
- throw new BadRequestException('Booking is not a general contract');
- }
- if (contract.status !== 'CONTRACT_ACTIVE') {
- throw new BadRequestException(
- `Contract is ${contract.status} β orders can only be placed against an ACTIVE contract`,
- );
- }
- if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
- throw new BadRequestException('Contract ordering window has expired');
- }
-
- // The customer placing the order must own the contract.
- if (userId && !(await this.userOwnsContract(userId, contract))) {
- throw new BadRequestException('You do not have access to this contract');
- }
-
- // Resolve the route the order ships on: a chosen contract route line for a
- // multi-route contract, else the contract's own origin/destination.
- const routeLines = await this.generalContractService.getRouteLines(
- contract.id,
- );
- let originYardId = contract.originYardId;
- let destinationYardId = contract.destinationYardId;
- let routeLineId: string | null = null;
- let routeKm: number | null = null;
-
- if (routeLines.length > 0) {
- if (!dto.routeLineId) {
- throw new BadRequestException(
- 'This contract has multiple routes β select a route to draw from',
- );
- }
- const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
- if (!chosen) {
- throw new BadRequestException(
- 'Selected route is not part of this contract',
- );
- }
- originYardId = chosen.originYardId;
- destinationYardId = chosen.destinationYardId;
- routeLineId = chosen.routeLineId;
- routeKm = chosen.km ?? null;
- }
-
- // Validate the route has a departure on the chosen day.
- const day = eatDay(new Date(dto.scheduledDate));
- const hasDeparture =
- await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
- originYardId,
- destinationYardId,
- day,
- );
- if (!hasDeparture) {
- throw new BadRequestException(
- 'No departures available on the selected day for this route',
- );
- }
-
- const isContainer = contract.freightType === 'CONTAINER';
-
- // Hazardous/reefer counts the customer entered cannot exceed the line they
- // belong to. Validated for every order regardless of routing.
- for (const line of dto.lines) {
- const haz = line.hazardousQuantity ?? 0;
- const reefer = line.reeferQuantity ?? 0;
- if (haz < 0 || reefer < 0) {
- throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
- }
- if (haz > line.quantity || reefer > line.quantity) {
- throw new BadRequestException(
- 'Hazardous/reefer quantity cannot exceed the line quantity',
- );
- }
- }
-
- // The contract has a single shared drawdown pool (per container type for
- // CONTAINER, or one bulk bucket). Routes are pure lanes β the chosen route
- // only fixed origin/destination/km above β so every order, routed or not,
- // validates each line against the same shared pool.
- const poolLines = await this.generalContractService.getQuantityLines(
- contract.id,
- );
- for (const line of dto.lines) {
- if (line.quantity <= 0) {
- throw new BadRequestException('Order quantities must be greater than zero');
- }
- const key = isContainer ? (line.containerTypeId ?? '') : '';
- const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
- if (!poolLine) {
- throw new BadRequestException(
- isContainer
- ? `Container type ${line.containerTypeId} is not part of this contract`
- : 'This contract has no matching quantity pool',
- );
- }
- if (line.quantity > poolLine.remainingQuantity) {
- throw new BadRequestException(
- `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
- (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
- );
- }
- }
-
- // Persist the order + its child shipment booking atomically.
- const order = await this.dataSource.transaction(async (manager) => {
- const childBooking = await this.spawnChildBooking(
- contract,
- dto,
- { originYardId, destinationYardId, km: routeKm },
- manager,
- );
-
- const reference = await this.generateReference();
- const orderRow = manager.create(BookingOrder, {
- reference,
- contractBookingId: contract.id,
- bookingId: childBooking.id,
- routeLineId,
- companyId: contract.companyId ?? null,
- scheduledDate: new Date(dto.scheduledDate),
- // The order is a ledger row; the child booking drives the workflow
- // (review β pay β allocate), so the order tracks PENDING until done.
- status: 'PENDING',
- schedulingStatus: 'NOT_SCHEDULED',
- });
- const savedOrder = await manager.save(orderRow);
-
- const lines = dto.lines.map((l) =>
- manager.create(BookingOrderLine, {
- orderId: savedOrder.id,
- containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
- quantity: l.quantity,
- hazardousQuantity: l.hazardousQuantity ?? 0,
- reeferQuantity: l.reeferQuantity ?? 0,
- }),
- );
- await manager.save(lines);
- savedOrder.lines = lines;
- return savedOrder;
- });
-
- // The child does NOT enter the train batch pool here. It is priced and
- // unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
- // clearance first; the batch enqueue happens only on accept.
-
- // Close the contract once its pool is exhausted (pending orders count, so
- // the pool reserves quantity as soon as an order is placed).
- if (await this.generalContractService.isExhausted(contract.id)) {
- await this.dataSource
- .getRepository(Booking)
- .update(contract.id, { status: 'CONTRACT_CLOSED' });
- this.logger.log(
- `Contract ${contract.reference} CLOSED β quantity exhausted`,
- );
- }
-
- return (await this.ordersRepository.findById(order.id)) ?? order;
- }
-
- /**
- * Create the ONE_TIME child booking for an order, inheriting the contract's
- * shipment context. Unlike the contract (which is no longer paid up front),
- * the child is PRICED and UNPAID and waits for Marketing review β going
- * through the customs clearance gate first when the service includes customs,
- * mirroring a one-time booking. It only enters the train pool on accept.
- */
- private async spawnChildBooking(
- contract: Booking,
- dto: CreateBookingOrderDto,
- route: { originYardId: string; destinationYardId: string; km: number | null },
- manager: import('typeorm').EntityManager,
- ): Promise {
- const reference = await this.generateChildBookingReference();
- const isContainer = contract.freightType === 'CONTAINER';
-
- // Sum line quantities Γ the contract's per-unit weight for the child total.
- const containerByType = new Map(
- (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
- );
- let totalWeight = 0;
- if (isContainer) {
- for (const line of dto.lines) {
- const src = containerByType.get(line.containerTypeId ?? '');
- const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
- totalWeight += vgmPerUnit * line.quantity;
- }
- } else {
- totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
- }
-
- // Per-order hazardous/reefer: set the child flags from the order's line
- // counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
- const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
- const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
-
- // Customs orders flow through the one-time clearance gate first; others go
- // straight to operations review with the chosen shipment day.
- const { includesCustoms } = clearanceCodesForBooking(contract);
- const spawnStatus = includesCustoms
- ? 'AWAITING_DOCUMENTS'
- : 'OPERATION_REQUEST_PENDING';
-
- const child = manager.create(Booking, {
- reference,
- companyId: contract.companyId ?? null,
- companyProfileId: contract.companyProfileId ?? null,
- isGovernment: contract.isGovernment,
- governmentInstitution: contract.governmentInstitution ?? null,
- contractType: contract.contractType,
- previousContractId: contract.id,
- serviceTypeId: contract.serviceTypeId,
- firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
- lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
- equipmentReturn: contract.equipmentReturn,
- originYardId: route.originYardId,
- destinationYardId: route.destinationYardId,
- tradeDirection: contract.tradeDirection,
- freightType: contract.freightType,
- cargoTypeId: contract.cargoTypeId ?? null,
- cargoFreeText: contract.cargoFreeText ?? null,
- shippingLineId: contract.shippingLineId ?? null,
- cargoTotalWeightVgm: totalWeight,
- isHazardous: hasHazardous,
- isReefer: hasReefer,
- paymentCurrency: contract.paymentCurrency,
- bookingType: 'ONE_TIME',
- scheduledDate: new Date(dto.scheduledDate),
- // Priced + unpaid: the customer pays this order on its own.
- status: spawnStatus,
- paymentStatus: 'PENDING',
- priorityScore: contract.priorityScore,
- totalAmount: 0,
- schedulingStatus: 'NOT_SCHEDULED',
- });
- const savedChild = await manager.save(child);
-
- if (isContainer) {
- for (const line of dto.lines) {
- const src = containerByType.get(line.containerTypeId ?? '');
- const ct = line.containerTypeId
- ? await manager.getRepository(ContainerType).findOne({
- where: { id: line.containerTypeId },
- })
- : null;
- const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
- const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
- const row = manager.create(BookingContainer, {
- bookingId: savedChild.id,
- containerTypeId: line.containerTypeId ?? null,
- quantity: line.quantity,
- vgmPerUnitTons: vgmPerUnit,
- totalVgmTons: vgmPerUnit * line.quantity,
- wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
- isOverweight: false,
- });
- await manager.save(row);
- }
- }
-
- // Price the order: base freight for the drawn quantity + haz/reefer
- // surcharges, plus a road KM charge when the service ships by road.
- const roadKm = isRoadService(contract.serviceType) ? route.km : null;
- await this.priceChildBooking(savedChild.id, roadKm, manager);
-
- return savedChild;
- }
-
- /**
- * Compute and persist the child order's price (base + surcharges) inside the
- * order transaction. The contract is no longer paid up front, so each order
- * carries its own total that the customer pays.
- */
- private async priceChildBooking(
- childId: string,
- roadKm: number | null,
- manager: import('typeorm').EntityManager,
- ): Promise {
- const child = await manager.getRepository(Booking).findOne({
- where: { id: childId },
- relations: { bookingContainers: true },
- });
- if (!child) return;
-
- try {
- const computed = await this.pricingService.computePriceForBooking(child);
- const lineItems = [...computed.lineItems];
- let total = computed.totalAmount;
-
- // Road KM charge: distance Γ the live PER_KM rate, added as its own line.
- if (roadKm && roadKm > 0) {
- const perKmRate = await this.findPerKmRate(child.paymentCurrency);
- const kmAmount = roadKmPrice(roadKm, perKmRate);
- if (kmAmount > 0) {
- lineItems.push({
- code: 'ROAD_KM',
- description: `Road transport (${roadKm} km)`,
- amount: kmAmount,
- unitAmount: perKmRate!,
- unit: 'PER_KM',
- quantity: roadKm,
- currency: child.paymentCurrency,
- });
- total += kmAmount;
- }
- }
-
- await manager.getRepository(Booking).update(childId, {
- totalAmount: total,
- priorityScore: computed.priorityScore,
- pricingBreakdown: {
- lineItems,
- totalAmount: total,
- currency: computed.currency,
- generatedAt: new Date().toISOString(),
- },
- } as never);
- } catch (err) {
- this.logger.error(
- `Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
-
- /** The live PER_KM rate value for road billing, in the given currency. */
- private async findPerKmRate(currency: string): Promise {
- const rates = await this.ratesService.findLiveRates();
- const rate = rates.find(
- (r) => r.rateUnit === 'PER_KM' && r.currency === currency,
- );
- return rate ? Number(rate.rateValue) : null;
- }
-
- private async userOwnsContract(
- userId: string,
- contract: Booking,
- ): Promise {
- if (!contract.companyId) return true; // government / staff-created
- try {
- const { company } = await this.companiesService.getCompanyInfoByUserId(
- userId,
- );
- return company.id === contract.companyId;
- } catch {
- return false;
- }
- }
-
- private async generateReference(): Promise {
- const year = new Date().getFullYear();
- const count = await this.ordersRepository.countByYear(year);
- return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
- }
-
- private async generateChildBookingReference(): Promise {
- const year = new Date().getFullYear();
- const count = await this.bookingsRepository.countByYear(year);
- return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
- }
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts
deleted file mode 100644
index bfdcc72b8..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { CargoUnitOfMeasure } from '@edr/types';
-
-/** A single contracted/ordered/remaining pool line for a general contract. */
-export class ContractQuantityLineView {
- @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
- containerTypeId!: string | null;
-
- @ApiProperty({ nullable: true })
- containerTypeName!: string | null;
-
- @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
- unitOfMeasure!: CargoUnitOfMeasure | null;
-
- @ApiProperty()
- contractedQuantity!: number;
-
- @ApiProperty()
- orderedQuantity!: number;
-
- @ApiProperty()
- remainingQuantity!: number;
-}
-
-/**
- * A contracted route (lane) of a general contract. Routes are pure
- * originβdestination lanes the contract covers; they carry NO quantity. The
- * contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
- * and an order picks one lane (for scheduling/billing) while drawing from that
- * shared pool.
- */
-export class ContractRouteLineView {
- @ApiProperty({ description: 'Contract route line id' })
- routeLineId!: string;
-
- @ApiProperty()
- originYardId!: string;
-
- @ApiProperty({ nullable: true })
- originYardName!: string | null;
-
- @ApiProperty()
- destinationYardId!: string;
-
- @ApiProperty({ nullable: true })
- destinationYardName!: string | null;
-
- @ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
- km!: number | null;
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts
deleted file mode 100644
index d7d226855..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import 'reflect-metadata';
-import { plainToInstance } from 'class-transformer';
-import { CreateBookingOrderLineDto } from './create-booking-order.dto';
-
-/**
- * Order line haz/reefer quantities arrive as JSON numbers but must default to 0
- * when omitted and coerce string inputs (defensive) to numbers.
- */
-describe('CreateBookingOrderLineDto β haz/reefer coercion', () => {
- const toDto = (plain: Record) =>
- plainToInstance(CreateBookingOrderLineDto, plain, {
- enableImplicitConversion: false,
- exposeDefaultValues: true,
- }) as unknown as CreateBookingOrderLineDto;
-
- it('defaults hazardous/reefer quantities to 0 when omitted', () => {
- const dto = toDto({ quantity: 5 });
- expect(dto.hazardousQuantity).toBe(0);
- expect(dto.reeferQuantity).toBe(0);
- });
-
- it('coerces provided string quantities to numbers', () => {
- const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
- expect(dto.quantity).toBe(5);
- expect(dto.hazardousQuantity).toBe(2);
- expect(dto.reeferQuantity).toBe(3);
- });
-});
diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts
deleted file mode 100644
index 4e7382ec4..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { Transform, Type } from 'class-transformer';
-import {
- ArrayMinSize,
- IsArray,
- IsDateString,
- IsNumber,
- IsOptional,
- IsUUID,
- Min,
- ValidateNested,
-} from 'class-validator';
-
-export class CreateBookingOrderLineDto {
- @ApiPropertyOptional({
- format: 'uuid',
- description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
- })
- @IsOptional()
- @IsUUID()
- containerTypeId?: string;
-
- @ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
- @IsNumber()
- @Min(0)
- @Transform(({ value }) => Number(value))
- quantity!: number;
-
- @ApiPropertyOptional({
- description: 'How much of this line is hazardous (β€ quantity). Defaults to 0.',
- minimum: 0,
- })
- @IsOptional()
- @IsNumber()
- @Min(0)
- @Transform(({ value }) => Number(value ?? 0))
- hazardousQuantity?: number = 0;
-
- @ApiPropertyOptional({
- description: 'How much of this line is refrigerated (β€ quantity). Defaults to 0.',
- minimum: 0,
- })
- @IsOptional()
- @IsNumber()
- @Min(0)
- @Transform(({ value }) => Number(value ?? 0))
- reeferQuantity?: number = 0;
-}
-
-export class CreateBookingOrderDto {
- @ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
- @IsUUID()
- contractBookingId!: string;
-
- @ApiPropertyOptional({
- format: 'uuid',
- description:
- 'For multi-route contracts: the contract route line being drawn from. ' +
- 'Determines the shipment origin/destination. Omit for single-route contracts.',
- })
- @IsOptional()
- @IsUUID()
- routeLineId?: string;
-
- @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
- @IsDateString()
- scheduledDate!: string;
-
- @ApiProperty({ type: [CreateBookingOrderLineDto] })
- @IsArray()
- @ArrayMinSize(1)
- @ValidateNested({ each: true })
- @Type(() => CreateBookingOrderLineDto)
- lines!: CreateBookingOrderLineDto[];
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts
deleted file mode 100644
index d6fff951a..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { BaseEntity } from '@edr/api-common';
-import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
-import { ContainerType } from '../../rule-engine/entities/container-type.entity';
-import { BookingOrder } from './booking-order.entity';
-
-/**
- * Postgres `numeric` columns are serialized to JS strings by the driver. This
- * transformer hydrates them back into real numbers so consumers (and the
- * `quantity: number` API type) don't have to coerce on every read.
- */
-const numericColumn = {
- to: (value: number) => value,
- from: (value: string | null) => (value == null ? value : Number(value)),
-};
-
-/**
- * One drawn-down quantity line of an order. For CONTAINER contracts there is one
- * line per container type (matching the contract's pools); for BULK/BREAK_BULK a
- * single line with a null containerTypeId carries the tons/items.
- */
-@Entity({ schema: 'freight', name: 'booking_order_lines' })
-export class BookingOrderLine extends BaseEntity {
- @Column({ name: 'order_id', type: 'uuid' })
- orderId!: string;
-
- @ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
- @JoinColumn({ name: 'order_id' })
- order?: BookingOrder;
-
- @Column({ name: 'container_type_id', type: 'uuid', nullable: true })
- containerTypeId?: string | null;
-
- @ManyToOne(() => ContainerType, { nullable: true })
- @JoinColumn({ name: 'container_type_id' })
- containerType?: ContainerType | null;
-
- /** Containers (count), tons, or items depending on the contract's freight/UoM. */
- @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
- quantity!: number;
-
- /**
- * How much of this line is hazardous / refrigerated, entered per order by the
- * customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
- * REEFER_SURCHARGE rates on the spawned child booking. Both β€ quantity.
- */
- @Column({
- name: 'hazardous_quantity',
- type: 'numeric',
- precision: 12,
- scale: 3,
- default: 0,
- transformer: numericColumn,
- })
- hazardousQuantity!: number;
-
- @Column({
- name: 'reefer_quantity',
- type: 'numeric',
- precision: 12,
- scale: 3,
- default: 0,
- transformer: numericColumn,
- })
- reeferQuantity!: number;
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts
deleted file mode 100644
index 610496d79..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { BaseEntity } from '@edr/api-common';
-import { SchedulingStatus } from '@edr/types';
-import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
-import { Booking } from '../../bookings/entities/booking.entity';
-import { Company } from '../../companies/entities/company.entity';
-import { BookingOrderLine } from './booking-order-line.entity';
-
-/**
- * A single drawdown against a general contract. Each order spawns its own
- * ONE_TIME child Booking (the shipment that enters the train scheduling
- * pipeline); this row is the ledger entry linking the contract to that
- * shipment and recording the drawn-down quantities.
- */
-@Entity({ schema: 'freight', name: 'booking_orders' })
-export class BookingOrder extends BaseEntity {
- @Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
- reference!: string;
-
- /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
- @Column({ name: 'contract_booking_id', type: 'uuid' })
- contractBookingId!: string;
-
- @ManyToOne(() => Booking)
- @JoinColumn({ name: 'contract_booking_id' })
- contractBooking?: Booking;
-
- /** The ONE_TIME child shipment booking spawned for this order. */
- @Column({ name: 'booking_id', type: 'uuid', nullable: true })
- bookingId?: string | null;
-
- @ManyToOne(() => Booking, { nullable: true })
- @JoinColumn({ name: 'booking_id' })
- booking?: Booking | null;
-
- /** Denormalized from the contract for fast company-scoped filtering. */
- @Column({ name: 'company_id', type: 'uuid', nullable: true })
- companyId?: string | null;
-
- @ManyToOne(() => Company, { nullable: true })
- @JoinColumn({ name: 'company_id' })
- company?: Company | null;
-
- /**
- * The contract route line this order drew down (multi-route general contracts).
- * Null for legacy/single-route contracts that have no route lines β the order
- * then uses the contract's own origin/destination.
- */
- @Column({ name: 'route_line_id', type: 'uuid', nullable: true })
- routeLineId?: string | null;
-
- @Column({ name: 'scheduled_date', type: 'timestamptz' })
- scheduledDate!: Date;
-
- @Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
- status!: string;
-
- @Column({
- name: 'scheduling_status',
- type: 'varchar',
- length: 30,
- default: SchedulingStatus.NotScheduled,
- })
- schedulingStatus!: string;
-
- @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
- trainScheduleId?: string | null;
-
- @OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
- lines?: BookingOrderLine[];
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts
deleted file mode 100644
index e05af758b..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { BaseEntity } from '@edr/api-common';
-import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
-import { Booking } from '../../bookings/entities/booking.entity';
-import { ContainerType } from '../../rule-engine/entities/container-type.entity';
-import { Yard } from '../../rule-engine/entities/yard.entity';
-
-/**
- * One contracted route+quantity line of a GENERAL contract. A general contract
- * may span several routes (e.g. AddisβDire Dawa: 10, ModjoβDjibouti: 5); each
- * route reserves its own quantity pool. Drawdown orders pick one of these routes
- * and decrement that route's pool. One-time bookings do not use this β they keep
- * the single origin/destination on the booking itself.
- */
-@Entity({ schema: 'freight', name: 'contract_route_lines' })
-@Index(['contractBookingId'])
-export class ContractRouteLine extends BaseEntity {
- /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
- @Column({ name: 'contract_booking_id', type: 'uuid' })
- contractBookingId!: string;
-
- @ManyToOne(() => Booking)
- @JoinColumn({ name: 'contract_booking_id' })
- contractBooking?: Booking;
-
- @Column({ name: 'origin_yard_id', type: 'uuid' })
- originYardId!: string;
-
- @ManyToOne(() => Yard)
- @JoinColumn({ name: 'origin_yard_id' })
- originYard?: Yard;
-
- @Column({ name: 'destination_yard_id', type: 'uuid' })
- destinationYardId!: string;
-
- @ManyToOne(() => Yard)
- @JoinColumn({ name: 'destination_yard_id' })
- destinationYard?: Yard;
-
- /**
- * Container type this route line reserves (CONTAINER contracts); null for
- * BULK/BREAK_BULK, where the quantity is tons/items.
- */
- @Column({ name: 'container_type_id', type: 'uuid', nullable: true })
- containerTypeId?: string | null;
-
- @ManyToOne(() => ContainerType, { nullable: true })
- @JoinColumn({ name: 'container_type_id' })
- containerType?: ContainerType | null;
-
- /** Contracted quantity for this (route, container type): containers, tons, or items. */
- @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
- quantity!: number;
-
- /**
- * Road distance for this route, configured with the route. Road (truck)
- * drawdown orders bill KM Γ the PER_KM rate from this value. Null for
- * rail-only routes where KM is not billed.
- */
- @Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
- km?: number | null;
-}
diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts
deleted file mode 100644
index 58122bd65..000000000
--- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import { Injectable, Logger, NotFoundException } from '@nestjs/common';
-import { BookingType, CargoUnitOfMeasure } from '@edr/types';
-import { DataSource } from 'typeorm';
-import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
-import { Booking } from '../bookings/entities/booking.entity';
-import { BookingOrder } from './entities/booking-order.entity';
-import { ContractRouteLine } from './entities/contract-route-line.entity';
-import {
- ContractQuantityLineView,
- ContractRouteLineView,
-} from './dto/contract-view.dto';
-
-/** Setting code holding the global ordering window (in months) for general contracts. */
-export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
-/** Fallback when the setting is missing or unparseable. */
-export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
-
-/**
- * Owns general-contract concerns that sit alongside the generic booking flow:
- * the configurable ordering period, post-payment activation, and computing the
- * remaining drawdown pool per contract.
- */
-@Injectable()
-export class GeneralContractService {
- private readonly logger = new Logger(GeneralContractService.name);
-
- constructor(
- private readonly dataSource: DataSource,
- private readonly dropdownSettings: DropdownSettingsService,
- ) {}
-
- isGeneralContract(booking: Pick): boolean {
- return booking.bookingType === BookingType.GeneralContract;
- }
-
- /** The configured ordering window in months (defaults to 3). */
- async getPeriodMonths(): Promise {
- try {
- const setting = await this.dropdownSettings.getByCode(
- CONTRACT_PERIOD_SETTING_CODE,
- );
- const raw = setting.children?.[0]?.value;
- const months = Number(raw);
- if (Number.isFinite(months) && months > 0) return months;
- } catch {
- // Setting not seeded yet β fall back to the default.
- }
- return DEFAULT_CONTRACT_PERIOD_MONTHS;
- }
-
- /**
- * Called when a general contract's payment succeeds: mark it ACTIVE (instead of
- * entering the train queue like a one-time booking) and stamp the ordering
- * window. Idempotent.
- */
- async activateAfterPayment(bookingId: string): Promise {
- const repo = this.dataSource.getRepository(Booking);
- const booking = await repo.findOne({ where: { id: bookingId } });
- if (!booking || !this.isGeneralContract(booking)) return;
- if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
- return;
- }
-
- const months = await this.getPeriodMonths();
- const expiresAt = new Date();
- expiresAt.setMonth(expiresAt.getMonth() + months);
-
- await repo.update(bookingId, {
- status: 'CONTRACT_ACTIVE',
- paymentStatus: 'PAID',
- expiresAt,
- });
- this.logger.log(
- `General contract ${booking.reference} ACTIVE β ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
- );
- }
-
- /**
- * The drawdown pool for a contract: contracted vs. ordered vs. remaining,
- * per container type for CONTAINER contracts, or a single total line for
- * BULK/BREAK_BULK (keyed on a null container type).
- */
- async getQuantityLines(
- contractBookingId: string,
- ): Promise {
- const booking = await this.dataSource.getRepository(Booking).findOne({
- where: { id: contractBookingId },
- relations: { bookingContainers: { containerType: true }, cargoType: true },
- });
- if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
-
- const ordered = await this.orderedByContainerType(contractBookingId);
-
- if (booking.freightType === 'CONTAINER') {
- return (booking.bookingContainers ?? []).map((c) => {
- const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
- const contracted = Number(c.quantity);
- return {
- containerTypeId: c.containerTypeId ?? null,
- containerTypeName: c.containerType?.label ?? null,
- unitOfMeasure: null,
- contractedQuantity: contracted,
- orderedQuantity: orderedQty,
- remainingQuantity: Math.max(0, contracted - orderedQty),
- };
- });
- }
-
- // BULK / BREAK_BULK β a single pool keyed on the contracted total weight/items.
- const orderedQty = ordered.get('') ?? 0;
- const contracted = Number(booking.cargoTotalWeightVgm);
- const uom: CargoUnitOfMeasure | null =
- (booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
- CargoUnitOfMeasure.PerTon;
- return [
- {
- containerTypeId: null,
- containerTypeName: null,
- unitOfMeasure: uom,
- contractedQuantity: contracted,
- orderedQuantity: orderedQty,
- remainingQuantity: Math.max(0, contracted - orderedQty),
- },
- ];
- }
-
- /**
- * The contracted routes (lanes) of a multi-route general contract β pure
- * originβdestination pairs the contract covers. Routes carry NO quantity; the
- * contract draws from a single shared pool ({@link getQuantityLines}). An order
- * picks one lane (for scheduling + road billing) and draws from that pool.
- * Returns [] for single-route contracts (no route lines) β callers then use the
- * contract's own origin/destination.
- */
- async getRouteLines(
- contractBookingId: string,
- ): Promise {
- const routeLines = await this.dataSource
- .getRepository(ContractRouteLine)
- .find({
- where: { contractBookingId },
- relations: {
- originYard: true,
- destinationYard: true,
- },
- order: { createdAt: 'ASC' },
- });
-
- return routeLines.map((rl) => ({
- routeLineId: rl.id,
- originYardId: rl.originYardId,
- originYardName: rl.originYard?.label ?? null,
- destinationYardId: rl.destinationYardId,
- destinationYardName: rl.destinationYard?.label ?? null,
- km: rl.km != null ? Number(rl.km) : null,
- }));
- }
-
- /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
- private async orderedByContainerType(
- contractBookingId: string,
- ): Promise