diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 78c15cba1..fcd560a95 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -50,7 +50,7 @@ jobs:
SERVICES=()
- NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
+ NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index b0850737b..f9107ed23 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -3,6 +3,10 @@
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
+# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
+# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
@@ -14,6 +18,7 @@ FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
+ --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM base AS builder
@@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..."
FROM base AS deployer
COPY --from=builder /app/ .
-RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
+ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
diff --git a/apps/edr-freight-api/html/payment-tester.html b/apps/edr-freight-api/html/payment-tester.html
new file mode 100644
index 000000000..1bb1a5c80
--- /dev/null
+++ b/apps/edr-freight-api/html/payment-tester.html
@@ -0,0 +1,452 @@
+
+
+
+
+
+EDR Freight β Payment Tester (Telebirr ETB + Card USD)
+
+
+
+
+ π EDR Freight β Payment Tester
+ Telebirr (ETB)
+ Card (USD)
+
+
+
+
+
+ API connection
+
+
+ Test connection
+ not checked
+
+
+
+
+
+ 1 Β· Choose a booking
+
+
+ Booking ID (UUID) β paste directly, or load the list below
+
+
+
Load this booking
+
+
+ List recent bookings
+ Currency (ETB vs USD) is set per-booking via paymentCurrency. Pick an ETB booking to test Telebirr, a USD booking to test Card.
+
+
+
+
+
+
+
+
+ 2 Β· Initiate payment
+
+
+ Platform
+ web (browser redirect) mobile (launch app)
+
+
+ Payer account (optional β Waafi MWALLET / phone)
+
+
+
+
+ Pay with TelebirrETB Β· Ethiopian mobile money
+ Pay with CardUSD Β· Visa / Mastercard
+
+
+
+ open provider checkout in new tab
+
+
+
+ Telebirr β forces method TELEBIRR. Card β forces method CARD.
+ Each calls POST {base}/payments/initiate and follows the returned clientAction (REDIRECT url for web).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 27737c84c..9edf388b9 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -6,7 +6,7 @@
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
- "dev": "nest start --watch",
+ "dev": "nest start --watch --clearScreen false",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
@@ -18,18 +18,21 @@
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
+ "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
+ "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
- "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
+ "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
+ "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 9026a4dbc..db05ae26f 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
+import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -56,6 +57,7 @@ import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-inte
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
+import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
@@ -79,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
- // EventEmitterModule.forRoot(),
+ EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -144,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
+ GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
@@ -173,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
+ private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
@@ -199,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
+ // Government entities (with importer/exporter profiles) that government
+ // bookings bill to. Idempotent β keyed by fixed IDs.
+ await this.govCompaniesSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts
deleted file mode 100644
index b7846bac9..000000000
--- a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { MigrationInterface, QueryRunner } from 'typeorm';
-
-export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
- name = 'AddPostPaymentCompletedColumn1719667261000';
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
- await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
- await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
- }
-}
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-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/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/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/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts
new file mode 100644
index 000000000..55239c13f
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts
@@ -0,0 +1,71 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Extend `freight.invoices` into the billing record of record for every source
+ * (booking, demurrage, warehouse fees, β¦) so warehouse fee invoices can be
+ * centralized onto it instead of the parallel `warehouse_fee_invoices` table.
+ *
+ * Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
+ * a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
+ * statuses the warehouse flow uses.
+ *
+ * Matches billing/entities/invoice.entity.ts. All columns are additive with
+ * defaults, so existing booking/demurrage rows are unaffected.
+ */
+export class ExtendInvoicesForPartialPayment1828000000000
+ implements MigrationInterface
+{
+ name = "ExtendInvoicesForPartialPayment1828000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
+ // as the value is not referenced in the same transaction (it is not here).
+ await queryRunner.query(
+ `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
+ );
+ await queryRunner.query(
+ `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
+ );
+
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS paid_at timestamptz,
+ ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
+ `);
+
+ // Backfill existing rows: subtotal mirrors the total (no tax was modeled),
+ // the outstanding balance is the full total for unpaid invoices.
+ await queryRunner.query(`
+ UPDATE freight.invoices
+ SET subtotal_amount = total_amount,
+ balance_amount = total_amount;
+ `);
+
+ // Already-settled invoices: fully paid, zero balance, stamped from updated_at.
+ await queryRunner.query(`
+ UPDATE freight.invoices
+ SET paid_amount = total_amount,
+ balance_amount = 0,
+ paid_at = updated_at
+ WHERE status = 'PAID';
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.invoices
+ DROP COLUMN IF EXISTS payments,
+ DROP COLUMN IF EXISTS paid_at,
+ DROP COLUMN IF EXISTS balance_amount,
+ DROP COLUMN IF EXISTS paid_amount,
+ DROP COLUMN IF EXISTS tax_amount,
+ DROP COLUMN IF EXISTS subtotal_amount;
+ `);
+ // Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
+ // left on freight.invoices_status_enum (harmless, unused after down).
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts
new file mode 100644
index 000000000..dd246cb7d
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts
@@ -0,0 +1,222 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Fold warehouse fee invoices into the central billing system.
+ *
+ * Warehouse fee invoices are no longer a standalone aggregate: each becomes a
+ * global `freight.invoices` row (`source = 'warehouse'`, `source_id =
+ * inventory_id`) with its items as `freight.invoice_lines`. The warehouse
+ * service is now a thin layer over `BillingService`. This migration backfills the
+ * existing rows (preserving ids, numbers, status, amounts and payment history),
+ * then drops the two legacy tables.
+ *
+ * Rows that cannot be billed centrally β no company to bill (`company_id` /
+ * `company_profile_id` underivable from the customer or the booking) β are not
+ * migrated; they could never have been charged through the gateway and are
+ * dropped with the table.
+ */
+export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
+ name = 'CentralizeWarehouseInvoices1829000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // 1. Invoice headers. Keep the same id so items still link, and so any
+ // external reference to the invoice id stays valid.
+ await queryRunner.query(`
+ INSERT INTO freight.invoices (
+ id, invoice_number, company_id, company_profile_id,
+ subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
+ currency, status, source, source_id, type,
+ issued_at, paid_at, payments, payment_id, due_at,
+ created_at, updated_at, deleted_at
+ )
+ SELECT
+ fee.id,
+ fee.invoice_number,
+ COALESCE(fee.customer_id, b.company_id),
+ COALESCE(
+ b.company_profile_id,
+ (SELECT cp.id
+ FROM freight.company_profiles cp
+ WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
+ AND cp.deleted_at IS NULL
+ ORDER BY cp.created_at ASC
+ LIMIT 1)
+ ),
+ fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
+ fee.currency,
+ fee.status::freight.invoices_status_enum,
+ 'warehouse',
+ fee.inventory_id,
+ fee.invoice_type,
+ fee.issued_at,
+ fee.paid_at,
+ COALESCE(fee.payments, '[]'::jsonb),
+ NULL,
+ COALESCE(fee.due_date, fee.issued_at, fee.created_at),
+ fee.created_at, fee.updated_at, fee.deleted_at
+ FROM freight.warehouse_fee_invoices fee
+ LEFT JOIN freight.bookings b ON b.id = fee.booking_id
+ WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
+ AND COALESCE(
+ b.company_profile_id,
+ (SELECT cp.id
+ FROM freight.company_profiles cp
+ WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
+ AND cp.deleted_at IS NULL
+ ORDER BY cp.created_at ASC
+ LIMIT 1)
+ ) IS NOT NULL
+ ON CONFLICT (id) DO NOTHING;
+ `);
+
+ // 2. Invoice lines β only for items whose parent invoice migrated. Warehouse
+ // fee fields (fee_rule_id / chargeable_days / free_days) move into the
+ // line's jsonb metadata.
+ await queryRunner.query(`
+ INSERT INTO freight.invoice_lines (
+ id, invoice_id, charge_type, description, quantity, unit_rate, amount,
+ currency, metadata, created_at, updated_at, deleted_at
+ )
+ SELECT
+ item.id,
+ item.invoice_id,
+ item.fee_type,
+ item.description,
+ item.quantity,
+ item.unit_rate,
+ item.amount,
+ item.currency,
+ jsonb_build_object(
+ 'feeRuleId', item.fee_rule_id,
+ 'chargeableDays', item.chargeable_days,
+ 'freeDays', item.free_days
+ ),
+ item.created_at, item.updated_at, item.deleted_at
+ FROM freight.warehouse_fee_invoice_items item
+ JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
+ ON CONFLICT (id) DO NOTHING;
+ `);
+
+ // 3. Drop the legacy tables (items first β FK to invoices).
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Recreate the legacy tables β¦
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ invoice_number varchar(40) NOT NULL,
+ booking_id uuid,
+ customer_id uuid,
+ inventory_id uuid NOT NULL,
+ facility_id uuid,
+ warehouse_id uuid,
+ yard_id uuid,
+ zone_id uuid,
+ invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
+ status varchar(20) NOT NULL DEFAULT 'DRAFT',
+ subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
+ tax_amount numeric(14,2) NOT NULL DEFAULT 0,
+ total_amount numeric(14,2) NOT NULL DEFAULT 0,
+ paid_amount numeric(14,2) NOT NULL DEFAULT 0,
+ balance_amount numeric(14,2) NOT NULL DEFAULT 0,
+ currency varchar(8) NOT NULL DEFAULT 'USD',
+ period_start timestamptz,
+ period_end timestamptz,
+ issued_at timestamptz,
+ due_date timestamptz,
+ paid_at timestamptz,
+ cancelled_at timestamptz,
+ payments jsonb NOT NULL DEFAULT '[]',
+ notes text,
+ CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
+ CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
+ );
+ `);
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
+ );
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ invoice_id uuid NOT NULL,
+ fee_rule_id uuid,
+ fee_type varchar(32) NOT NULL,
+ description varchar(255) NOT NULL,
+ quantity numeric(12,2) NOT NULL DEFAULT 1,
+ unit_rate numeric(14,2) NOT NULL DEFAULT 0,
+ amount numeric(14,2) NOT NULL DEFAULT 0,
+ currency varchar(8) NOT NULL DEFAULT 'USD',
+ chargeable_days int,
+ free_days int,
+ CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
+ CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
+ FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
+ );
+ `);
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
+ );
+
+ // β¦ then copy the warehouse-source invoices back, deriving the typed FKs and
+ // period from the linked inventory item.
+ await queryRunner.query(`
+ INSERT INTO freight.warehouse_fee_invoices (
+ id, created_at, updated_at, deleted_at, invoice_number,
+ booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
+ invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
+ currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
+ )
+ SELECT
+ i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
+ inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
+ i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
+ i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
+ CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
+ i.payments, NULL
+ FROM freight.invoices i
+ LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
+ LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
+ WHERE i.source = 'warehouse'
+ ON CONFLICT (id) DO NOTHING;
+ `);
+ await queryRunner.query(`
+ INSERT INTO freight.warehouse_fee_invoice_items (
+ id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
+ description, quantity, unit_rate, amount, currency, chargeable_days, free_days
+ )
+ SELECT
+ l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
+ NULLIF(l.metadata->>'feeRuleId', '')::uuid,
+ l.charge_type,
+ COALESCE(l.description, ''),
+ l.quantity, l.unit_rate, l.amount, l.currency,
+ NULLIF(l.metadata->>'chargeableDays', '')::int,
+ NULLIF(l.metadata->>'freeDays', '')::int
+ FROM freight.invoice_lines l
+ JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
+ ON CONFLICT (id) DO NOTHING;
+ `);
+
+ // Remove the migrated rows from the central tables.
+ await queryRunner.query(`
+ DELETE FROM freight.invoice_lines
+ WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
+ `);
+ await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
+ }
+}
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..dc78cd6e9 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,26 @@
-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 { DocumentsModule } from "./documents/documents.module";
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,
+ DocumentsModule,
+ ],
+ 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..61597264b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
@@ -0,0 +1,338 @@
+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
+ {} as never, // invoiceDocuments
+ );
+ });
+
+ 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(/^INV-\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
+ {} as never, // invoiceDocuments
+ );
+
+ 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
+ {} as never, // invoiceDocuments
+ );
+
+ await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
+
+ expect(mg.update).not.toHaveBeenCalled();
+ expect(events.emit).not.toHaveBeenCalled();
+ });
+});
+
+describe("BillingService.recordPayment", () => {
+ function serviceFor(invoice: Record | null) {
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(invoice),
+ 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
+ {} as never, // invoiceDocuments
+ );
+ return { service, mg, events };
+ }
+
+ const openInvoice = (overrides: Record = {}) => ({
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Issued,
+ source: "warehouse",
+ sourceId: "inv-item-1",
+ totalAmount: 1000,
+ paidAmount: 0,
+ balanceAmount: 1000,
+ payments: [],
+ paidAt: null,
+ ...overrides,
+ });
+
+ it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
+ const { service, mg, events } = serviceFor(openInvoice());
+
+ const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
+
+ expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
+ expect(updated.paidAmount).toBe(400);
+ expect(updated.balanceAmount).toBe(600);
+ expect(updated.payments).toHaveLength(1);
+ expect(mg.update).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: "inv-1" },
+ expect.objectContaining({
+ status: Freight.InvoiceStatus.PartiallyPaid,
+ paidAmount: 400,
+ balanceAmount: 600,
+ }),
+ );
+ expect(events.emit).not.toHaveBeenCalled();
+ });
+
+ it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
+ const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
+
+ const updated = await service.recordPayment("inv-1", { amount: 600 });
+
+ expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
+ expect(updated.balanceAmount).toBe(0);
+ expect(updated.paidAt).toBeInstanceOf(Date);
+ expect(mg.update).toHaveBeenCalled();
+ expect(events.emit).toHaveBeenCalledWith(
+ "warehouse.invoice.paid",
+ expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
+ );
+ });
+
+ it("rejects a non-positive amount", async () => {
+ const { service, mg } = serviceFor(openInvoice());
+ await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
+ expect(mg.update).not.toHaveBeenCalled();
+ });
+
+ it("rejects payment against a cancelled invoice", async () => {
+ const { service, mg } = serviceFor(
+ openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
+ );
+ await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
+ expect(mg.update).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
+ {} as never, // invoiceDocuments
+ );
+
+ 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
+ {} as never, // invoiceDocuments
+ );
+
+ 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..f1b58ad5e 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,716 @@
-import { Injectable } from "@nestjs/common";
-import { InjectRepository } from "@nestjs/typeorm";
-import { Repository } from "typeorm";
+import { Freight, PaymentReferenceType } from "@edr/types";
+import {
+ BadRequestException,
+ forwardRef,
+ Inject,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from "@nestjs/common";
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import { DataSource, EntityManager, In } from "typeorm";
-import { Invoice } from "./entities/invoice.entity";
+import { CompaniesService } from "../companies/companies.service";
+import { PaymentService } from "../payment/payment.service";
+import { InitiateResponseDto } from "../payment/payments.dto";
+import {
+ InvoiceDocumentModel,
+ InvoiceDocumentService,
+} from "./documents/invoice-document.service";
+import { InvoiceLine } from "./entities/invoice-line.entity";
+import { Invoice, InvoicePayment } from "./entities/invoice.entity";
+import { InvoiceLineRepository } from "./invoice-line.repository";
+import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
+import { applySettlement, round2 } from "./invoice-settlement.util";
+import { InvoiceRepository } from "./invoice.repository";
+
+/** Options forwarded to the payment gateway when settling an invoice. */
+export interface PayInvoiceOptions {
+ method?: string;
+ platform?: "web" | "mobile";
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+}
+
+/** A single manual/offline settlement to record against an invoice. */
+export interface RecordPaymentInput {
+ /** Amount settled by this payment; must be > 0. */
+ amount: number;
+ method?: string | null;
+ reference?: string | null;
+ /** When the settlement occurred; defaults to now. */
+ paidAt?: Date;
+ metadata?: Record | null;
+}
+
+/** 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.Issued,
+ Freight.InvoiceStatus.Pending,
+ Freight.InvoiceStatus.PartiallyPaid,
+ 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 pre-tax subtotal; defaults to the sum of line amounts. */
+ subtotalAmount?: number;
+ /** Tax applied on top of the subtotal; defaults to 0. */
+ taxAmount?: number;
+ /** Explicit total; defaults to `subtotalAmount + taxAmount`. */
+ 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,
+ private readonly invoiceDocuments: InvoiceDocumentService,
+ ) { }
+
+ // ββ 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[] };
+ }
+
+ // ββ Documents (central PDF) ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /** Sealed PDF invoice for any source, rendered by the shared document service. */
+ async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
+ const invoice = await this.findById(id);
+ return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE"));
+ }
+
+ /** Sealed PDF receipt; available once any payment has been recorded. */
+ async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
+ const invoice = await this.findById(id);
+ if (Number(invoice.paidAmount) <= 0) {
+ throw new BadRequestException("A receipt is available only after payment is recorded.");
+ }
+ return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT"));
+ }
+
+ /** Map a global invoice (+ lines) onto the source-agnostic document model. */
+ private toDocumentModel(
+ invoice: Invoice & { lines: InvoiceLine[] },
+ kind: "INVOICE" | "RECEIPT",
+ ): InvoiceDocumentModel {
+ const title = invoice.source
+ ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
+ : "EDR";
+ const totals: InvoiceDocumentModel["totals"] = [
+ { label: "Subtotal", amount: Number(invoice.subtotalAmount) },
+ ];
+ if (Number(invoice.taxAmount) > 0) {
+ totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
+ }
+ totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true });
+ totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
+ totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
+
+ return {
+ kind,
+ title,
+ documentNumber: invoice.invoiceNumber,
+ issuedAt: invoice.issuedAt ?? invoice.createdAt,
+ status: invoice.status,
+ currency: invoice.currency,
+ summary: [
+ { label: "Status", value: invoice.status },
+ { label: "Type", value: invoice.type },
+ { label: "Reference", value: invoice.sourceId },
+ { label: "Currency", value: invoice.currency },
+ { label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null },
+ { label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null },
+ ],
+ categoryHeader: "Charge type",
+ lines: invoice.lines.map((l) => ({
+ description: l.description ?? l.chargeType,
+ category: l.chargeType,
+ quantity: l.quantity,
+ unitRate: l.unitRate,
+ amount: l.amount,
+ currency: l.currency,
+ })),
+ totals,
+ };
+ }
+
+ // ββ 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /** `-YYYYMMDD-00001` β sequential per day & prefix, within the active transaction. */
+ private nextInvoiceNumber(mg: EntityManager): Promise {
+ return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" });
+ }
+
+ /**
+ * 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 subtotalAmount =
+ input.subtotalAmount ??
+ lines.reduce((sum, l) => sum + Number(l.amount), 0);
+ const taxAmount = input.taxAmount ?? 0;
+ const totalAmount =
+ input.totalAmount ?? round2(subtotalAmount + taxAmount);
+
+ 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,
+ subtotalAmount: round2(subtotalAmount),
+ taxAmount: round2(taxAmount),
+ totalAmount: round2(totalAmount),
+ paidAmount: 0,
+ balanceAmount: round2(totalAmount),
+ payments: [],
+ 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,
+ );
+ }
+
+ /**
+ * Record a (possibly partial) settlement against an invoice and sync its
+ * status. Appends to the `payments` ledger, recomputes `paidAmount` /
+ * `balanceAmount`, and moves the invoice to PARTIALLY_PAID or β once the
+ * balance reaches zero β PAID, stamping `paidAt` and emitting
+ * `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
+ * at the warehouse counter); gateway settlement goes through
+ * {@link markInvoiceAsPaid}.
+ *
+ * Throws when the invoice is missing, cancelled, refunded, already fully paid,
+ * or when `amount` is not positive. Pass `manager` to enlist in a caller's
+ * transaction.
+ */
+ async recordPayment(
+ invoiceId: string,
+ input: RecordPaymentInput,
+ manager?: EntityManager,
+ ): Promise {
+ if (!(input.amount > 0)) {
+ throw new BadRequestException("Payment amount must be greater than zero.");
+ }
+
+ 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 === Freight.InvoiceStatus.Cancelled) {
+ throw new BadRequestException("Cannot pay a cancelled invoice.");
+ }
+ if (invoice.status === Freight.InvoiceStatus.Refunded) {
+ throw new BadRequestException("Cannot pay a refunded invoice.");
+ }
+ if (invoice.status === Freight.InvoiceStatus.Paid) {
+ throw new BadRequestException("Invoice is already fully paid.");
+ }
+
+ const at = input.paidAt ?? new Date();
+ const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
+ invoice.totalAmount,
+ invoice.paidAmount,
+ input.amount,
+ );
+ const status = fullyPaid
+ ? Freight.InvoiceStatus.Paid
+ : Freight.InvoiceStatus.PartiallyPaid;
+
+ const entry: InvoicePayment = {
+ amount: round2(input.amount),
+ method: input.method ?? null,
+ reference: input.reference ?? null,
+ paidAt: at.toISOString(),
+ metadata: input.metadata ?? null,
+ };
+ const payments = [...(invoice.payments ?? []), entry];
+
+ await mg.update(
+ Invoice,
+ { id: invoice.id },
+ {
+ paidAmount,
+ balanceAmount,
+ status,
+ payments,
+ paidAt: fullyPaid ? at : invoice.paidAt ?? null,
+ } as never,
+ );
+
+ const updated = {
+ ...invoice,
+ paidAmount,
+ balanceAmount,
+ status,
+ payments,
+ paidAt: fullyPaid ? at : invoice.paidAt ?? null,
+ } as Invoice;
+
+ if (fullyPaid) this.emitInvoiceEvent("paid", updated);
+ return updated;
+ }
+
+ /**
+ * 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,
+ // Freight payments settle under the generic SHIPMENT reference β how the
+ // payment service attributes them to the freight API. The payment β invoice
+ // link is the intent id (`paymentId`); per-source post-payment reactions live
+ // in the domain via `${source}.invoice.paid`. Neither billing nor the payment
+ // service branches on a domain-specific reference type.
+ referenceType: PaymentReferenceType.SHIPMENT,
+ 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/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts
new file mode 100644
index 000000000..c320a5d44
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts
@@ -0,0 +1,16 @@
+import { Module } from "@nestjs/common";
+
+import { InvoiceDocumentService } from "./invoice-document.service";
+import { PdfRenderService } from "./pdf-render.service";
+
+/**
+ * Standalone document infrastructure β generic HTMLβPDF plus the shared
+ * invoice/receipt renderer. Has no domain dependencies, so any module (billing,
+ * warehouses, β¦) can import it to print invoices without coupling to the
+ * billing payment graph.
+ */
+@Module({
+ providers: [PdfRenderService, InvoiceDocumentService],
+ exports: [PdfRenderService, InvoiceDocumentService],
+})
+export class DocumentsModule {}
diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts
new file mode 100644
index 000000000..a07087f8f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts
@@ -0,0 +1,179 @@
+import { Injectable } from "@nestjs/common";
+
+import { PdfRenderService } from "./pdf-render.service";
+
+export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
+
+/** One billed line on the document (charge type / fee type agnostic). */
+export interface InvoiceDocumentLine {
+ description: string | null;
+ /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
+ category?: string | null;
+ quantity?: number | null;
+ unitRate?: number | null;
+ amount?: number | null;
+ currency?: string | null;
+}
+
+/** A labelled total row in the totals box; mark `grand` for the headline total. */
+export interface InvoiceDocumentTotal {
+ label: string;
+ amount: number;
+ grand?: boolean;
+}
+
+/**
+ * Source-agnostic description of a printable invoice/receipt. Each billing
+ * source maps its own entity onto this shape; the renderer owns the layout so
+ * every EDR invoice document looks identical regardless of source.
+ */
+export interface InvoiceDocumentModel {
+ kind: InvoiceDocumentKind;
+ /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
+ title: string;
+ documentNumber: string;
+ issuedAt?: Date | string | null;
+ status: string;
+ currency: string;
+ /** Free-form summary grid (label/value pairs). */
+ summary: Array<{ label: string; value: string | null }>;
+ /** Header for the line-item category column; column hidden when omitted. */
+ categoryHeader?: string;
+ lines: InvoiceDocumentLine[];
+ totals: InvoiceDocumentTotal[];
+ /** Override the round seal text; defaults from kind/status. */
+ sealText?: string;
+}
+
+/**
+ * Central invoice/receipt PDF renderer shared by every billing source. Turns a
+ * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
+ * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
+ * `WarehouseInvoiceService`; it now serves all invoices.
+ */
+@Injectable()
+export class InvoiceDocumentService {
+ constructor(private readonly pdf: PdfRenderService) {}
+
+ async render(
+ model: InvoiceDocumentModel,
+ ): Promise<{ filename: string; buffer: Buffer }> {
+ const html = this.buildHtml(model);
+ const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
+ return {
+ filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
+ buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
+ };
+ }
+
+ buildHtml(model: InvoiceDocumentModel): string {
+ const esc = (value: unknown) =>
+ String(value ?? "-")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ const money = (amount: unknown, currency = model.currency) =>
+ `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
+ const date = (value: unknown) =>
+ value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
+
+ const showCategory = Boolean(model.categoryHeader);
+ const sealText =
+ model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
+
+ const summaryRows = model.summary
+ .map((row) => `${esc(row.label)} ${esc(row.value)}
`)
+ .join("");
+
+ const itemRows = model.lines
+ .map(
+ (item) => `
+ ${esc(item.description)}
+ ${showCategory ? `${esc((item.category ?? "").replace(/_/g, " "))} ` : ""}
+ ${esc(item.quantity ?? 0)}
+ ${esc(money(item.unitRate, item.currency ?? model.currency))}
+ ${esc(money(item.amount, item.currency ?? model.currency))}
+ `,
+ )
+ .join("");
+
+ const totalRows = model.totals
+ .map(
+ (total) =>
+ `${esc(total.label)} ${esc(money(total.amount))}
`,
+ )
+ .join("");
+
+ return `
+
+
+
+ ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}
+
+
+
+
+
+
+
Ethio-Djibouti Railway S.C.
+
${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}
+
+
+ Document no.
+ ${esc(model.documentNumber)}
+ Issued: ${esc(date(model.issuedAt))}
+
+
+
${esc(sealText)}
+
${summaryRows}
+
+
+
+ Description
+ ${showCategory ? `${esc(model.categoryHeader)} ` : ""}
+ Qty
+ Rate
+ Amount
+
+
+
+ ${itemRows}
+
+
+
${totalRows}
+
+
+
+`;
+ }
+
+ safeFilename(value: string): string {
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts
new file mode 100644
index 000000000..447bc2516
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts
@@ -0,0 +1,160 @@
+import { existsSync } from "fs";
+
+import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
+
+const MIN_VALID_PDF_BYTES = 2_000;
+
+const PDF_PRINT_STYLES = `
+`;
+
+export interface PdfRenderOptions {
+ /** Label used in logs to identify the document kind. */
+ label?: string;
+ /**
+ * Degraded renderer used when Chromium is unavailable. Receives the
+ * print-prepared HTML and must return a valid PDF buffer (β₯ 2KB, `%PDF-`
+ * header). When omitted, a generic single-page fallback is produced.
+ */
+ fallback?: (preparedHtml: string) => Buffer;
+}
+
+/**
+ * Generic HTML β PDF renderer shared by every document producer (invoices,
+ * receipts, warehouse release orders). Renders via headless Chromium when
+ * available and degrades to a caller-supplied (or generic) hand-built PDF
+ * otherwise. This is pure infrastructure β it knows nothing about invoices.
+ */
+@Injectable()
+export class PdfRenderService {
+ private readonly logger = new Logger(PdfRenderService.name);
+
+ async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise {
+ const label = opts.label ?? "document";
+ const preparedHtml = this.injectPdfPrintStyles(html);
+ const executablePath = this.resolveExecutablePath();
+
+ try {
+ const puppeteer = await import("puppeteer");
+ const launchOptions: import("puppeteer").LaunchOptions = {
+ headless: true,
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
+ ...(executablePath ? { executablePath } : {}),
+ };
+
+ const browser = await puppeteer.default.launch(launchOptions);
+ try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
+ await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
+ await page.emulateMediaType("print");
+ await new Promise((resolve) => setTimeout(resolve, 250));
+
+ const pdf = await page.pdf({
+ format: "A4",
+ printBackground: true,
+ margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
+ });
+
+ const buffer = Buffer.from(pdf);
+ if (!this.isValidPdf(buffer)) {
+ throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
+ }
+ this.logger.log(
+ `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
+ );
+ return buffer;
+ } finally {
+ await browser.close();
+ }
+ } catch (error) {
+ this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
+ const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
+ if (this.isValidPdf(fallback)) {
+ this.logger.warn(
+ `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
+ );
+ return fallback;
+ }
+ throw new InternalServerErrorException(
+ `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
+ );
+ }
+ }
+
+ private injectPdfPrintStyles(html: string): string {
+ if (html.includes("edr-pdf-print-fix")) return html;
+ if (html.includes("")) {
+ return html.replace("", `${PDF_PRINT_STYLES}`);
+ }
+ return `${PDF_PRINT_STYLES}${html}`;
+ }
+
+ private resolveExecutablePath(): string | undefined {
+ const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
+
+ const candidates = [
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/usr/bin/google-chrome-stable",
+ "/usr/bin/google-chrome",
+ ];
+ return candidates.find((path) => existsSync(path));
+ }
+
+ isValidPdf(buffer: Buffer): boolean {
+ return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
+ }
+
+ /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
+ private genericFallbackPdf(html: string): Buffer {
+ const text = html
+ .replace(/
+