merge conflict

This commit is contained in:
marshal
2026-06-30 05:17:56 +03:00
126 changed files with 7248 additions and 2122 deletions

View File

@@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
CMD ["node", "dist/main.js"]
CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"]

View File

@@ -22,13 +22,16 @@
"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:*",

View File

@@ -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";
@@ -53,8 +54,10 @@ import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
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";
@@ -78,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 =>
@@ -143,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
@@ -150,6 +154,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
],
})
@@ -168,8 +173,10 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
@@ -187,6 +194,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
@@ -195,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();
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1810000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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');
}
}
}

View File

@@ -0,0 +1,101 @@
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 (`<table>_<column>_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<void> {
await queryRunner.query(`
CREATE TYPE freight.invoices_status_enum AS ENUM (
'DRAFT',
'PENDING',
'PAID',
'OVERDUE',
'CANCELLED',
'REFUNDED'
);
`);
await queryRunner.query(`
CREATE TABLE 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<void> {
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;`);
}
}

View File

@@ -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<void> {
// 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<void> {
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.
}
}

View File

@@ -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<void> {
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<void> {
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;`,
);
}
}

View File

@@ -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);
}
}

View File

@@ -1,14 +1,24 @@
import { Module } from "@nestjs/common";
import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentModule } from "../payment/payment.module";
import { CompaniesModule } from "../companies/companies.module";
@Module({
imports: [TypeOrmModule.forFeature([Invoice])],
controllers: [BillingController],
providers: [BillingService],
imports: [
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
],
controllers: [BillingController, PortalBillingController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,250 @@
import { Freight } from "@edr/types";
import { BillingService } from "./billing.service";
/**
* Minimal in-memory EntityManager stand-in covering the methods
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
*/
function makeManager(savedLines: unknown[]) {
return {
create: (_entity: unknown, data: Record<string, unknown>) => data,
save: (data: Record<string, unknown>) => {
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<string, unknown> = {}) {
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<typeof makeManager>;
let events: ReturnType<typeof makeEvents>;
let dataSource: { transaction: jest.Mock; manager: unknown };
let service: BillingService;
beforeEach(() => {
savedLines = [];
manager = makeManager(savedLines);
events = makeEvents();
dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
});
it("creates a PENDING invoice with one line per input line", async () => {
const invoice = await service.generateInvoice(generateInput());
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
expect(invoice.companyId).toBe("company-1");
expect(invoice.source).toBe("booking");
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
it("sums line amounts when no explicit totalAmount is given", async () => {
const invoice = await service.generateInvoice(
generateInput({ totalAmount: undefined }),
);
expect(invoice.totalAmount).toBe(1500);
});
it("leaves issuedAt null for a DRAFT invoice", async () => {
const invoice = await service.generateInvoice(
generateInput({ status: Freight.InvoiceStatus.Draft }),
);
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
expect(invoice.issuedAt).toBeNull();
});
it("enlists in a caller's transaction when a manager is passed", async () => {
await service.generateInvoice(generateInput(), manager as never);
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(savedLines).toHaveLength(2);
});
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({
invoiceId: "inv-1",
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
}),
);
});
it("is a no-op (no event) when the invoice is already paid", async () => {
const paid = {
id: "inv-1",
status: Freight.InvoiceStatus.Paid,
source: "booking",
};
const mg = {
findOne: jest.fn().mockResolvedValue(paid),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});

View File

@@ -1,26 +1,542 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Overdue,
];
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
description?: string;
/** Units this line bills for; defaults to 1. */
quantity?: number;
/** Price per unit; defaults to 0. */
unitRate?: number;
/** Line total; defaults to `quantity * unitRate`. */
amount?: number;
currency?: string;
metadata?: Record<string, unknown> | null;
}
/** Everything needed to generate an invoice for any source. */
export interface GenerateInvoiceInput {
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
source: Freight.InvoiceSource;
/** Identifier of the source record (e.g. booking id). */
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
dueInDays?: number;
/**
* Initial status. DRAFT leaves `issuedAt` null; any issued status
* (default PENDING) stamps `issuedAt`.
*/
status?: Freight.InvoiceStatus;
}
/** Payload broadcast on `${source}.invoice.<event>`. */
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<Invoice>,
) {}
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
/** List every invoice (most recent first). */
findAll(): Promise<Invoice[]> {
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoicesRepository.find({
where: { bookingId },
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
async resolveCompanyId(userId: string): Promise<string | null> {
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<Invoice[]> {
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<Invoice[]> {
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<Invoice & { lines: InvoiceLine[] }> {
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<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId);
return this.payInvoice(
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}
/**
* Generate an invoice for any source (booking, demurrage, manual, …).
*
* Persists the header plus its lines in one transaction and assigns the next
* sequential `invoice_number`. The total defaults to the sum of line amounts
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
*
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
* invoice as part of a larger booking flow).
*/
async generateInvoice(
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
return {
chargeType: l.chargeType,
description: l.description,
quantity,
unitRate,
amount: l.amount ?? quantity * unitRate,
currency: l.currency ?? currency,
metadata: l.metadata ?? null,
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const dueAt =
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber,
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
currency,
status,
issuedAt: issued ? new Date() : null,
dueAt,
}),
);
const savedLines = await Promise.all(
lines.map((l) =>
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
),
);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
);
return { ...invoice, lines: savedLines };
}
// ── State transitions ────────────────────────────────────────────────────────
/**
* Mark an invoice paid and link the gateway payment, then emit
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
* No-op when the invoice is already paid. Pass `manager` to enlist in a
* caller's transaction.
*/
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
*/
async markInvoiceAsRefunded(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
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<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Cancelled,
"cancelled",
{},
manager,
);
}
/**
* Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. 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<Invoice | null> {
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.<event>` 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<Invoice | null> {
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<Invoice | null> {
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<Invoice | null> {
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<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
}
const result = await this.payment.initiate({
referenceId: sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,
result.providerTxnId,
result.paidAt,
);
}
return result.response;
}
/**
* Settle the open invoice linked to a gateway intent id, if any. Called by the
* payment service when an intent succeeds: finds the invoice linked by
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
* to advance on. Idempotent — no-op when no open invoice is linked (already
* settled, or settled inline by {@link payInvoice}).
*/
async settleByPaymentId(
paymentId: string,
_providerTxnId?: string,
_paidAt?: Date,
): Promise<Invoice | null> {
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);
}
}

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
@IsOptional()
@IsString()
method?: string;
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: "web" | "mobile";
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
@IsOptional()
@IsString()
payerAccount?: string;
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
@IsOptional()
@IsString()
returnUrl?: string;
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
@IsOptional()
@IsString()
failureUrl?: string;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "./invoice.entity";
@Entity({ schema: "freight", name: "invoice_lines" })
export class InvoiceLine extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid", nullable: false })
invoiceId!: string;
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
@JoinColumn({ name: "invoice_id" })
invoice!: Invoice;
@Column({ name: "charge_type", type: "varchar", nullable: false })
chargeType!: string;
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
description?: string;
/** Units this line bills for (e.g. container count, wagon count, tons). */
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
quantity!: number;
/** Price per unit; `amount` is normally `quantity * unitRate`. */
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({
name: "amount",
type: "numeric",
precision: 14,
scale: 2,
nullable: false,
})
amount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({ name: "metadata", type: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -1,17 +1,35 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity } from "typeorm";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
@Entity({schema:"freight", name: "invoices" })
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
export class Invoice extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
amount!: number;
/** The customer (company) this invoice is billed to. */
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: "company_id" })
company?: Company;
/** The specific company profile (importer/exporter/forwarder/...) billed. */
@Column({ name: "company_profile_id", type: "uuid" })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile)
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
@Column({
name: "status",
type: "enum",
enum: Freight.PaymentStatus,
default: Freight.PaymentStatus.Pending,
enum: Freight.InvoiceStatus,
default: Freight.InvoiceStatus.Draft,
})
status!: Freight.PaymentStatus;
status!: Freight.InvoiceStatus;
@Column({ name: "issued_at", type: "timestamptz" })
issuedAt!: Date;
/** The source of the payment (e.g. booking, customer, etc.). */
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
source!: string;
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
sourceId!: string;
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
@Column({
type: "varchar",
length: 255,
nullable: false,
})
type!: string;
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;
@ManyToOne(() => PaymentEntity)
@JoinColumn({ name: "payment_id" })
payment?: PaymentEntity;
@Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date;

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { InvoiceLine } from "./entities/invoice-line.entity";
@Injectable()
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
constructor(
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
@Injectable()
export class InvoiceRepository extends BaseRepository<Invoice> {
constructor(
@InjectRepository(Invoice) repository: Repository<Invoice>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,60 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
* org-wide), every route here is force-scoped to the signed-in customer's
* company — they only ever see and pay their own invoices.
*/
@ApiTags("billing")
@ApiBearerAuth()
@Controller("billing")
export class PortalBillingController {
constructor(private readonly billingService: BillingService) {}
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(@CurrentUser() user: AuthUserPayload) {
return this.billingService.findForUser(resolveAuthUserId(user));
}
@Get("my-invoices/:id")
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
findMineById(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: PayInvoiceDto,
) {
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
method: dto.method,
platform: dto.platform ?? "web",
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
}

View File

@@ -0,0 +1,201 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
InvoiceLineInput,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileService } from '../first-mile/first-mile.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
}
/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
* turns into invoices, which type to use, and how it advances when paid. Bookings
* are the billable business entity, so they generate their own invoices directly
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class BookingInvoiceService {
private readonly logger = new Logger(BookingInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly bookingsRepository: BookingsRepository,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
) { }
/**
* Ensure the booking has its invoice, generating one from the snapshotted
* pricing breakdown if absent. Called when a booking reaches a billable state.
* Idempotent — returns the existing open invoice instead of a duplicate.
* Returns `null` (and logs) when the booking is not billable: no company to
* bill (e.g. government bookings whose `companyId` is null, which the invoices
* FK requires), or no priced amount.
*/
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
);
if (existing) return existing;
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
return this.billing.generateInvoice(input);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances
* the booking its own way. Only PREPAID exists today.
*/
@OnEvent('booking.invoice.paid')
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
* and is allocated into its batch. Idempotent — no-op when already PAID.
*
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
* lifecycle and ordering window live in the contracts module, advanced by the
* contract transition/clearance services — not by booking payment. Every
* booking that settles here is a ONE_TIME shipment, so there is no contract
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
*/
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
return;
}
if (booking.paymentStatus === 'PAID') return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
{ paymentStatus: 'PAID', status: 'PAID' },
);
await this.firstMile.acceptBooking(bookingId);
});
try {
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
chargeType: l.code,
description: l.description,
quantity: l.quantity,
unitRate: l.unitAmount,
amount: l.amount,
currency: l.currency ?? currency,
metadata: l.unit ? { unit: l.unit } : null,
}));
// Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) {
const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) return null;
lines.push({
chargeType: 'FREIGHT',
description: 'Rail freight',
quantity: 1,
unitRate: amount,
amount,
currency,
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
let totalAmount = subtotal;
// Honor a staff price override: bill the adjusted total, recording the delta
// as an ADJUSTMENT line so the lines still sum to the invoice total.
const adjusted = booking.adjustedTotalAmount;
if (adjusted != null && Number.isFinite(Number(adjusted))) {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
quantity: 1,
unitRate: delta,
amount: delta,
currency,
});
}
totalAmount = round2(Number(adjusted));
}
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
};
}
}

View File

@@ -0,0 +1,180 @@
import {
Body,
Controller,
Get,
HttpStatus,
Post,
Query,
Res,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiQuery,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
} from "../payment/payments.dto";
/**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/
@ApiTags("Payment")
@Controller("payments")
export class BookingPaymentController {
constructor(private readonly billing: BillingService) { }
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: "Charges the booking's open invoice through the payment gateway.",
})
@ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking,
bookingId,
{ method, platform },
);
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -1,49 +1,37 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
import { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
private readonly billing: BillingService,
) { }
/**
* Start payment for a booking. The booking never touches the payment gateway
* directly — it charges its invoice through billing, which resolves the amount
* and drives the provider. Returns the provider redirect URL (empty when none).
*/
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initiatePayment({
bookingId,
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: "web",
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}

View File

@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService

View File

@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,

View File

@@ -33,6 +33,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,

View File

@@ -3,6 +3,7 @@ import {
forwardRef,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
@@ -404,7 +409,22 @@ export class BookingTransitionService {
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {

View File

@@ -10,7 +10,11 @@ import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
@@ -33,7 +37,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@Module({
@@ -48,7 +51,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingReviewNote,
BookingContractSignature,
]),
PaymentModule,
BillingModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
@@ -63,7 +67,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [BookingsController, PayController],
controllers: [BookingsController, PayController, BookingPaymentController],
providers: [
BookingsService,
BookingsRepository,
@@ -72,6 +76,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingPricingService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
@@ -79,6 +84,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository, BookingPricingService],
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
})
export class BookingsModule {}

View File

@@ -12,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -307,10 +307,23 @@ export class BookingsService {
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
// Government bookings bill to a real seeded government company + an
// explicitly-chosen importer/exporter profile (no more null company +
// free-text institution).
if (!dto.companyId) {
throw new BadRequestException('A government company is required for government bookings');
}
companyId = dto.companyId ?? null;
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
if (govCompany.kind !== CompanyKind.Government) {
throw new BadRequestException('Selected company is not a government entity');
}
if (govCompany.status !== CompanyStatus.Active) {
throw new BadRequestException('Selected government company is not active');
}
if (!dto.companyProfileId) {
throw new BadRequestException('A government company profile is required for government bookings');
}
companyId = govCompany.id;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
@@ -383,7 +396,16 @@ export class BookingsService {
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
if (dto.companyProfileId && companyId) {
// Explicit profile pin (government booking, or staff booking on behalf):
// must belong to the chosen company and be active.
const profile =
await this.companiesService.getActiveCompanyProfileForBooking(
companyId,
dto.companyProfileId,
);
companyProfileId = profile.id;
} else if (companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
@@ -413,6 +435,16 @@ export class BookingsService {
}
}
// Every booking must link to a company and a company profile.
if (!companyId) {
throw new BadRequestException('A company is required to create a booking');
}
if (!companyProfileId) {
throw new BadRequestException(
'A company profile is required to create a booking — none could be resolved for this company',
);
}
const needsConsolidation =
dto.freightType === 'CONTAINER'
? await this.needsConsolidation(containers)
@@ -443,10 +475,10 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyId,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
governmentInstitution: dto.governmentInstitution?.trim() || null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,

View File

@@ -14,7 +14,6 @@ import {
Max,
MaxLength,
Min,
MinLength,
Validate,
ValidateIf,
ValidateNested,
@@ -104,19 +103,31 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
/** @deprecated Government bookings now bill to a real government company. */
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
@IsOptional()
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@ApiPropertyOptional({
format: 'uuid',
description:
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
})
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()

View File

@@ -105,8 +105,10 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
// Every booking is billed to a company — government bookings bill to a seeded
// government company (companies.kind = 'government'). Enforced NOT NULL.
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
@@ -116,11 +118,12 @@ export class Booking extends BaseEntity {
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
* legacy/government/staff-created bookings.
* Customer portal lists and dashboard KPIs are scoped by this. Required:
* commercial bookings resolve it from trade direction / active mode;
* government bookings carry the explicitly-picked government profile.
*/
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@Column({ name: 'company_profile_id', type: 'uuid' })
companyProfileId!: string;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })

View File

@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, status } = query;
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const qb = this.repository
.createQueryBuilder('company')
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.type = :type', { type });
}
if (kind) {
qb.andWhere('company.kind = :kind', { kind });
}
if (status) {
qb.andWhere('company.status = :status', { status });
}

View File

@@ -337,6 +337,29 @@ export class CompaniesService {
return company;
}
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff
* pick the profile) and any staff booking that pins a profile directly.
*/
async getActiveCompanyProfileForBooking(
companyId: string,
profileId: string,
): Promise<CompanyProfile> {
const profile = await this.companyProfilesRepo.findById(profileId);
if (!profile || profile.companyId !== companyId) {
throw new BadRequestException(
"Selected company profile does not belong to the chosen company",
);
}
if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException(
"Selected company profile is not active",
);
}
return profile;
}
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {

View File

@@ -1,7 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyStatus, CompanyType } from "../entities/company.entity";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyType))
type?: CompanyType;
@ApiPropertyOptional({ enum: CompanyKind })
@IsOptional()
@IsIn(Object.values(CompanyKind))
kind?: CompanyKind;
@ApiPropertyOptional({ enum: CompanyStatus })
@IsOptional()
@IsIn(Object.values(CompanyStatus))

View File

@@ -10,6 +10,16 @@ export enum CompanyType {
Transporter = "transporter",
}
/**
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
* of carrying a null company + free-text institution.
*/
export enum CompanyKind {
Commercial = "commercial",
Government = "government",
}
export enum CompanyStatus {
Active = "active",
Pending = "pending",
@@ -25,6 +35,7 @@ export enum CompanyNationality {
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@Index(["kind"])
export class Company extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
/** Commercial customer vs. the seeded government entity. */
@Column({
name: "kind",
type: "varchar",
length: 20,
default: CompanyKind.Commercial,
enum: CompanyKind,
})
kind!: CompanyKind;
@Column({
name: "status",
type: "varchar",

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
*/
@Injectable()
export class ContractBookingService {
private readonly logger = new Logger(ContractBookingService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
@@ -52,6 +56,7 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
) {}
@@ -199,6 +204,22 @@ export class ContractBookingService {
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(result ?? booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return { booking: result ?? booking, warnings };
}

View File

@@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -36,7 +36,7 @@ export class FirstMileService {
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
/**
@@ -251,16 +251,19 @@ export class FirstMileService {
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -34,6 +34,9 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -32,12 +32,11 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -185,16 +184,19 @@ export class LastMileService {
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
import { PaymentRefundEntity } from "./payment-refund.entity";
type PaymentType = "booking"
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
type PaymentType = string
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
type Currency = "ETB" | "USD"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: "enum", enum: ["booking"] })
@Column({ type: "varchar", length: 50 })
type!: PaymentType;
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
referenceType?: string;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
method!: PaymentMethod

View File

@@ -1,13 +1,13 @@
import {
Body,
Controller,
Get,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -20,14 +20,7 @@ import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { IntentStatusDto, RefundDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,16 +66,6 @@ export class PaymentController {
});
}
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
})
@ApiOkResponse({ type: InitiateResponseDto })
initiatePayment(@Body() dto: InitiatePaymentDto) {
return this.paymentService.initiatePayment(dto);
}
@Get("intents/:bookingId")
@ApiOperation({ summary: "Get payment intent status for a booking" })
@ApiOkResponse({ type: IntentStatusDto })
@@ -97,54 +80,6 @@ export class PaymentController {
return this.paymentService.refund(dto);
}
@Get("checkout")
@Public()
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
}
try {
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
if (url) {
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
}
return res
.status(HttpStatus.OK)
.type("html")
.send(this.buildStatusHtml(result.status, result.intentId));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
}
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
@@ -153,76 +88,4 @@ export class PaymentController {
const html = await this.paymentService.genReceiptHtml(orderId);
return res.status(HttpStatus.OK).type("html").send(html);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}

View File

@@ -1,4 +1,4 @@
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
@@ -12,9 +12,7 @@ import {
} from "@edr/types";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { BillingModule } from "../billing/billing.module";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentEntity } from "./entities/payment.entity";
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => BillingModule),
TypeOrmModule.forFeature([
PaymentEntity,
PaymentWebhookEventEntity,

View File

@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.service";
import * as fs from "fs";
import * as path from "path";
@@ -28,13 +29,44 @@ import {
ProviderMethod,
} from "@edr/types";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
RefundDto,
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
export interface InitiateIntentInput {
/** Opaque domain reference (booking id, …). */
referenceId: string;
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
source: string;
/** Gateway reference type the intent is opened with (caller's domain decides it). */
referenceType: PaymentReferenceType;
/** Human-readable order ref shown on provider pages. */
orderRef: string;
/** Authoritative amount in minor units, computed by the caller. */
amountMinor: number;
currency: string;
/** Stored on the intent projection for receipts/dashboards. */
reason?: string;
/** Provider/method selector. */
method: ProviderMethod | string;
platform?: PaymentPlatformDto;
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface InitiateIntentResult {
intentId: string;
response: InitiateResponseDto;
/** True when the provider settled the charge synchronously during initiate. */
immediateSuccess: boolean;
providerTxnId?: string;
paidAt?: Date;
}
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"refunded": ProviderPaymentStatus.CANCELLED,
};
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
/**
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
* NOTHING domain-specific: it never loads a booking, computes an amount, or
* advances a domain record. On settlement it notifies billing directly
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
*/
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
@@ -53,9 +102,8 @@ export class PaymentService {
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly firstMileService: FirstMileService,
@Inject(forwardRef(() => BillingService))
private readonly billing: BillingService,
) { }
async getAll(filters: {
@@ -123,7 +171,6 @@ export class PaymentService {
total += row.count;
}
// Sum of successfully collected amounts.
const paidAgg = await this.paymentRepo
.createQueryBuilder("payment")
.select("COALESCE(SUM(payment.amount), 0)", "sum")
@@ -141,65 +188,74 @@ export class PaymentService {
};
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
const amountMinor = Math.round(Number(booking.totalAmount));
/**
* Open a gateway intent for a caller-supplied amount/reference and project it
* locally. Returns the intent id (so billing can correlate the invoice) plus
* the client action. When the provider settles synchronously, the intent is
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: booking.id,
orderRef: booking.reference,
amountMinor,
currency: booking.paymentCurrency,
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
referenceType: input.referenceType,
referenceId: input.referenceId,
orderRef: input.orderRef,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,
payerAccount: input.payerAccount,
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
const intent = await this.upsertIntent(input, snapshot);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
// avoiding a settle-before-correlation race.
await this.markIntentSucceeded(intent.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
paidAt,
notify: false,
});
}
return this.formatIntentResponse(intent);
return {
intentId: intent.id,
// `intent` still reflects the projection status ("processing" on immediate
// success — settlement is applied by the caller, not shown synchronously).
response: this.formatIntentResponse(intent),
immediateSuccess,
providerTxnId: snapshot.providerTxnId,
paidAt,
};
}
private async syncIntentProjection(
bookingId: string,
booking: Booking,
/** Create or update the local intent projection from a provider snapshot. */
private async upsertIntent(
input: InitiateIntentInput,
snapshot: PaymentIntentSnapshot,
): Promise<PaymentEntity> {
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
const existing = await this.paymentRepo.findOneBy({
refId: input.referenceId,
});
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
TELEBIRR: "telebirr",
CBE_BIRR: "cbe-birr",
EBIRR: "ebirr",
WAAFI: "waafi",
CARD: "card",
DMONEY: "dmoney",
CAC_BANK: "cac-bank",
};
const method: PaymentEntity["method"] =
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const status =
snapshot.status === ProviderPaymentStatus.SUCCEEDED
? "processing"
: this.toLocalStatus(snapshot.status);
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
const clientAction = (snapshot.clientAction ?? undefined) as
| Record<string, unknown>
| undefined;
const data = {
status,
method,
@@ -216,30 +272,37 @@ export class PaymentService {
}
return this.paymentRepo.create({
refId: bookingId,
type: "booking",
amount: booking.totalAmount,
currency: booking.paymentCurrency,
reason: `Payment for booking ${booking.reference}`,
refId: input.referenceId,
type: input.source,
referenceType: input.referenceType,
amount: input.amountMinor,
currency: input.currency as PaymentEntity["currency"],
reason: input.reason ?? `Payment for ${input.orderRef}`,
rawInitiation: snapshot as unknown as Record<string, unknown>,
clientAction: clientAction ?? {},
...data,
} as any);
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
/**
* Reconcile an intent's status with the gateway by reference. Read-only on the
* domain side: it syncs the local projection and, when the provider reports a
* newly-observed success, notifies billing to settle. `referenceId` is opaque
* (the booking id, but this service does not load it).
*/
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.SHIPMENT,
bookingId,
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
referenceId,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
);
}
@@ -247,80 +310,59 @@ export class PaymentService {
if (!local) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(local);
}
if (!local) throw new NotFoundException("PaymentIntent not found");
const booking = await this.datasource
.getRepository(Booking)
.findOneBy({ id: bookingId });
// Sync local projection with provider-reported status.
const becameSuccess =
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
if (!booking) throw new NotFoundException("Booking not found");
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: booking.id,
if (becameSuccess) {
await this.markIntentSucceeded(local.id, {
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
notify: true,
});
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(snapshot.status),
failerCode: snapshot.failureCode ?? undefined,
failureMessage: snapshot.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
return this.formatIntentStatus(refreshed ?? intent);
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
await this.datasource.transaction(async (mg) => {
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
});
return { refunded: true, bookingId: dto.bookingId };
}
async finalizePaymentSuccess(input: {
intentId: string;
bookingId: string;
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
* when the caller settles inline and will trigger settlement itself.
*/
async markIntentSucceeded(
intentId: string,
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
const paidAt = opts.paidAt ?? new Date();
await this.paymentRepo.update(
{ id: intent.id },
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
);
// Every booking is a real shipment now (contracts are a separate aggregate),
// so payment always settles the booking to PAID and enters allocation.
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
await mg.update(
Booking,
{ id: input.bookingId },
{ paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
try {
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
} catch (err) {
this.logger.error(
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
);
if (opts.notify !== false) {
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
}
return { alreadyFinalized: false };
}
async markPaymentFailed(input: {
intentId: string;
@@ -335,6 +377,29 @@ export class PaymentService {
{ id: intent.id },
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
);
// Invoice stays open for retry — nothing to settle. Logged only.
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),
);
}
async refund(dto: RefundDto) {
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
if (!intent || intent.status !== "success") {
throw new BadRequestException("No successful payment to refund");
}
// NOTE: refunding still mutates the booking directly — left intact pending
// the refund redesign. TODO: route refunds through billing.refundPayable +
// a `${source}.invoice.refunded` reaction, like settlement.
await this.datasource.transaction(async (mg) => {
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
});
return { refunded: true, bookingId: dto.bookingId };
}
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
@@ -363,7 +428,7 @@ export class PaymentService {
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
return this.paymentRepo.findOneBy({ refId: id });
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
@@ -398,24 +463,37 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
console.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
bookingId: event.referenceId,
console.log(`Processing payment succeeded event for intent: }`,intent);
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// When the intent references a booking, flip the booking itself paid.
// refId holds the booking id (the domain reference the intent opened with).
if (intent.referenceType === PaymentReferenceType.BOOKING) {
await this.datasource.manager.update(
Booking,
{ id: intent.refId },
{ status: "PAID", paymentStatus: "PAID" },
);
}
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
}
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,

View File

@@ -13,7 +13,7 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
/** How long a selected commercial customer has to pay before their slot expires. */
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
export const PAYMENT_WINDOW_MS = 180 * 60 * 1000; // 5 minutes (test mode)
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;

View File

@@ -32,6 +32,9 @@ export interface ImportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
@@ -59,6 +62,9 @@ export interface ExportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
itemType: 'CONTAINER' | 'CARGO';
itemId: string | null;
inventoryId: string | null;
@@ -265,6 +271,9 @@ export class SchedulingReadFacade {
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
@@ -279,11 +288,24 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
ORDER BY b.reference ASC NULLS LAST`,
ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`,
[scheduleId],
);
return rows;
@@ -401,6 +423,9 @@ export class SchedulingReadFacade {
b.reference,
b.company_id,
company.name AS customer_name,
w.wagon_number,
tsw.sequence_no,
wba.allocated_weight_tons,
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
b.cargo_total_weight_vgm AS booking_weight,
oy.code AS origin,
@@ -412,6 +437,19 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -423,6 +461,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CONTAINER' AS "itemType",
c.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -441,6 +482,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CARGO' AS "itemType",
cg.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -460,6 +504,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
a.inventory_id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -474,7 +521,7 @@ export class SchedulingReadFacade {
FROM assigned a
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`,
[scheduleId],
);
return rows;

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
@@ -273,6 +273,25 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Post('bookings/:bookingId/approve-delivery')
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) =>
const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
export interface InventoryInquiryResult {
id: string;
inventoryId: string | null;
@@ -297,6 +300,9 @@ export interface ImportUnloadedRow {
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
deliveredAt: string | null;
}
@Injectable()
@@ -316,6 +322,7 @@ export class WarehouseInventoryService {
private readonly interchangeDocuments: InterchangeDocumentsService,
private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
) {}
/**
@@ -1020,6 +1027,9 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -1049,7 +1059,16 @@ export class WarehouseInventoryService {
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
return this.importQueueByStatuses([
'UNLOADED',
'DESTINATION_INSPECTION',
'UNDER_INSPECTION',
'ARRIVED_AT_WAREHOUSE',
'STORED',
'READY_FOR_PICKUP',
'DISPATCHED',
'DELIVERED',
]);
}
/**
@@ -2020,6 +2039,154 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.inspection_status = 'PASSED'
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
LIMIT 1`,
[bookingId],
);
if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
}
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
notes: this.appendNote(existingNotes, approvalNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
},
manager,
);
});
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
};
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
company.name AS "customerName",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode",
ts.train_number AS "trainSchedule"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed');
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
containerNumber: row.containerNumber ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
inspectionStatus: row.inspectionStatus ?? null,
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'DELIVERED');
@@ -2619,6 +2786,183 @@ export class WarehouseInventoryService {
</html>`;
}
private buildHandoverDocumentHtml(data: {
reference: string;
handedOverAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
freightType: string | null;
tradeDirection: string | null;
containerNumber: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
inspectionStatus: string | null;
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const fmt = (date: Date | string | null) => {
if (!date) return '-';
const parsed = date instanceof Date ? date : new Date(date);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const rows = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
];
const approval = data.customerApproval;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Goods Handover Document</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
.signature-meta { font-size: 11px; color: #061323; }
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Goods Handover Document</h1>
<div class="subtitle">EDR to customer warehouse handover</div>
</div>
<div class="ref">
Document No.
<strong>${esc(data.reference)}</strong>
Handover: ${esc(fmt(data.handedOverAt))}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
</div>
<div class="section-title">Handover Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Goods List</div>
<table>
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
<div class="clause">
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
inspection status, and release records before final physical handover.
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
</div>
</div>
</body>
</html>`;
}
private extractCustomerDeliveryApproval(notes?: string | null): {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null {
if (!notes) return null;
const line = notes
.split(/\r?\n/)
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
if (!line) return null;
try {
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
return {
approvedAt: String(parsed.approvedAt),
signerDisplayName: String(parsed.signerDisplayName),
signatureImageUrl: String(parsed.signatureImageUrl),
};
} catch {
return null;
}
}
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const lines = notes
.split(/\r?\n/)
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
return lines.join('\n').trim() || null;
}
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
throw new BadRequestException(`Invalid transition ${from}${to}`);

View File

@@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service';
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
NotificationsModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -0,0 +1,85 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
config({ path: resolve(__dirname, '../../.env') });
process.env.TYPEORM_LOGGING = 'false';
import { AppModule } from '../app.module';
import { deriveTradeDirection } from '../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn'],
});
try {
const dataSource = app.get(DataSource);
const inventory = app.get(WarehouseInventoryService);
const schedules: {
id: string;
trainNumber: string | null;
originCountry: string | null;
destinationCountry: string | null;
}[] = await dataSource.query(
`SELECT ts.id,
ts.train_number AS "trainNumber",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.status = 'ARRIVED'
AND ts.deleted_at IS NULL
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST,
ts.created_at DESC`,
);
const importSchedules = schedules.filter(
(schedule) =>
deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
) === 'IMPORT',
);
if (importSchedules.length === 0) {
console.log('No ARRIVED import trains found.');
return;
}
for (const schedule of importSchedules) {
const result = await inventory.autoUnloadArrivedBookings(
schedule.id,
'Demo Auto Unload',
);
console.log(
`${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`,
);
for (const item of result.results) {
console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`);
}
}
const queueRows = await inventory.importUnloadedQueue();
console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`);
const byStatus = queueRows.reduce<Record<string, number>>((acc, row) => {
acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1;
return acc;
}, {});
for (const [status, count] of Object.entries(byStatus)) {
console.log(` ${status}: ${count}`);
}
} finally {
await app.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,28 @@
import "reflect-metadata";
import { config } from "dotenv";
import { resolve } from "path";
config({ path: resolve(__dirname, "../../.env") });
import { NestFactory } from "@nestjs/core";
import { AppModule } from "../app.module";
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ["error", "warn", "log"],
});
try {
const seeder = app.get(GovCompaniesSeeder);
await seeder.run();
console.log("Government companies seeded.");
} finally {
await app.close();
}
}
main().catch((err) => {
console.error("Government companies seed failed:", err);
process.exit(1);
});

View File

@@ -1,16 +1,30 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
import { WagonStatus } from '@edr/types';
config({ path: resolve(__dirname, '../../.env') });
import { AppDataSource } from '../data-source';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const;
function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
@@ -25,18 +39,34 @@ async function main() {
const locomotiveRepo = manager.getRepository(Locomotive);
const trainSetRepo = manager.getRepository(TrainSet);
const scheduleRepo = manager.getRepository(TrainSchedule);
const wagonTypeRepo = manager.getRepository(WagonType);
const wagonRepo = manager.getRepository(Wagon);
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
const serviceTypeRepo = manager.getRepository(ServiceType);
const containerTypeRepo = manager.getRepository(ContainerType);
const companyRepo = manager.getRepository(Company);
const bookingRepo = manager.getRepository(Booking);
const bookingContainerRepo = manager.getRepository(BookingContainer);
const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking);
const allocationRepo = manager.getRepository(WagonBookingAllocation);
const containerItemRepo = manager.getRepository(WagonAllocationContainerItem);
const importOperationRepo = manager.getRepository(ImportDjiboutiOperation);
const negad =
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
(await yardRepo.save(
yardRepo.create({
code: 'NEGAD',
label: 'Negad',
label: 'Negad / Nagad',
country: 'Djibouti',
isActive: true,
displayOrder: 5,
}),
));
if (negad.label !== 'Negad / Nagad') {
negad.label = 'Negad / Nagad';
await yardRepo.save(negad);
}
const indode =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
@@ -64,6 +94,78 @@ async function main() {
}),
));
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ??
(await wagonTypeRepo.save(
wagonTypeRepo.create({
code: 'NEGAD-FLAT',
name: 'Negad Demo Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
equatedLengthM: 14,
tareWeightTons: 20,
supportsContainer: true,
maxContainerGrossT: 70,
}),
));
const containerType =
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
(await containerTypeRepo.save(
containerTypeRepo.create({
code: '40FT',
label: '40FT',
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: 2,
}),
));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.save(
serviceTypeRepo.create({
code: 'RAIL_CONTAINER',
serviceName: 'Rail Container Service',
description: 'Rail container service for demo marshalling',
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
}),
));
const company =
(await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ??
(await companyRepo.save(
companyRepo.create({
name: 'Negad Indode Marshalling Demo Customer',
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: 'NEGADIND01',
vatNumber: 'NEGADIND01',
fanNumber: 'NEGADINDODE00001',
country: 'Ethiopia',
address: 'Indode Dry Port',
phone: '251900000202',
email: 'negad-indode-demo@edr.local',
contactPersonName: 'Marshalling Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: 'negad-indode-demo@edr.local',
generalManagerPhone: '251900000202',
}),
));
const now = new Date();
const departure = addHours(now, -12);
const arrival = now;
@@ -75,7 +177,7 @@ async function main() {
locomotiveId: locomotive.id,
totalWeightTons: 960,
totalLengthMeters: 420,
wagonCount: 18,
wagonCount: BOOKING_REFS.length,
status: 'COMPLETED',
}),
);
@@ -111,9 +213,169 @@ async function main() {
}
const saved = await scheduleRepo.save(schedule);
await trainSetRepo.update(saved.trainSetId, {
totalWeightTons: BOOKING_REFS.length * 28,
totalLengthMeters: BOOKING_REFS.length * 14,
wagonCount: BOOKING_REFS.length,
status: 'COMPLETED',
});
const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } });
const existingAllocations = existingSlots.length
? await allocationRepo.find({
where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
})
: [];
if (existingAllocations.length) {
await containerItemRepo.delete(
existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })),
);
}
if (existingSlots.length) {
await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id })));
await wagonRepo.update(
existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
{
trainSetWagonId: null,
currentTrainScheduleId: null,
sequenceNumber: null,
status: WagonStatus.Available,
},
);
await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId });
}
for (const [index, reference] of BOOKING_REFS.entries()) {
const sequenceNo = index + 1;
const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`;
const weightTons = 26 + sequenceNo;
let booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) {
booking = bookingRepo.create({ reference });
}
Object.assign(booking, {
companyId: company.id,
originYardId: negad.id,
destinationYardId: indode.id,
serviceTypeId: serviceType.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: departure,
estimatedShipmentDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: `Negad to Indode demo container ${sequenceNo}`,
cargoTotalWeightVgm: weightTons,
priorityScore: 75 - index,
trainScheduleId: saved.id,
schedulingStatus: 'SCHEDULED',
scheduledAt: now,
wagonsRequired: 1,
});
booking = await bookingRepo.save(booking);
await bookingContainerRepo.delete({ bookingId: booking.id });
const bookingContainer = await bookingContainerRepo.save(
bookingContainerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerNumber,
quantity: 1,
vgmPerUnitTons: weightTons,
totalVgmTons: weightTons,
wagonsRequired: 1,
weightLimitRuleId: null,
isOverweight: false,
overweightExcessTons: null,
}),
);
await scheduleBookingRepo.upsert(
{ trainScheduleId: saved.id, bookingId: booking.id },
{ conflictPaths: { trainScheduleId: true, bookingId: true } },
);
const wagon = await wagonRepo.save(
wagonRepo.create({
wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`,
wagonTypeId: wagonType.id,
trainId: null,
sequenceNumber: sequenceNo,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Assigned,
currentYardId: indode.id,
notes: 'Demo wagon for Negad to Indode marshalling',
trainSetWagonId: null,
currentTrainScheduleId: saved.id,
}),
);
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: saved.trainSetId,
wagonTypeId: wagonType.id,
physicalWagonId: wagon.id,
sequenceNo,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: weightTons,
status: 'LOADED',
}),
);
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: 'CONTAINER',
status: 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
bookingContainerId: bookingContainer.id,
containerId: null,
containerNumber,
containerTypeId: containerType.id,
positionOnWagon: 1,
sealNumber: `SEAL-${containerNumber}`,
grossWeightTons: weightTons,
}),
);
}
await importOperationRepo.upsert(
{
trainScheduleId: saved.id,
documents: {},
gatepassGrantedAt: departure,
readyForLoadingAt: departure,
loadedOnTrainAt: departure,
departedFromDjiboutiAt: departure,
loadListGeneratedAt: now,
performedBy: 'Seed Demo',
notes: 'Seeded marshalling data for Negad to Indode arrived train',
},
{ conflictPaths: { trainScheduleId: true } },
);
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${saved.id}`);
console.log(`Route: ${negad.code} -> ${indode.code}`);
console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`);
});
} finally {
await dataSource.destroy();

View File

@@ -0,0 +1,136 @@
import {
CompanyKind,
CompanyStatus,
CompanyType,
} from "../../modules/companies/entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "../../modules/companies/entities/company-profile.entity";
/**
* Canonical list of seeded Ethiopian government entities. Government bookings
* are billed to one of these (with an explicit importer/exporter profile)
* instead of carrying a null company + free-text institution.
*
* IDs are fixed so the seeder is idempotent and the matching migration
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
* the same companies. The migration mirrors these rows in raw SQL — keep both
* in sync when adding new entities.
*/
export const GOV_COMPANY_TYPE = CompanyType.Customer;
export const GOV_COMPANY_KIND = CompanyKind.Government;
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
export interface GovProfileSeed {
id: string;
type: ProfileType;
reference: string;
}
export interface GovCompanySeed {
id: string;
name: string;
tin: string;
email: string;
phone: string;
profiles: GovProfileSeed[];
}
const importExport = (
index: number,
importerId: string,
exporterId: string,
): GovProfileSeed[] => [
{
id: importerId,
type: ProfileType.importer,
reference: `IM-9000${index}`,
},
{
id: exporterId,
type: ProfileType.exporter,
reference: `EX-9000${index}`,
},
];
export const GOV_COMPANIES: GovCompanySeed[] = [
{
id: "0a1b0001-0000-4000-8000-000000000001",
name: "Federal Government of Ethiopia",
tin: "0000000001",
email: "procurement@gov.et",
phone: "+251111000001",
profiles: importExport(
1,
"0b1c0001-0000-4000-8000-000000000001",
"0b1c0001-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0002-0000-4000-8000-000000000002",
name: "Ministry of National Defense",
tin: "0000000002",
email: "logistics@mod.gov.et",
phone: "+251111000002",
profiles: importExport(
2,
"0b1c0002-0000-4000-8000-000000000001",
"0b1c0002-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0003-0000-4000-8000-000000000003",
name: "Ethiopian Roads Administration",
tin: "0000000003",
email: "supply@era.gov.et",
phone: "+251111000003",
profiles: importExport(
3,
"0b1c0003-0000-4000-8000-000000000001",
"0b1c0003-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0004-0000-4000-8000-000000000004",
name: "Ministry of Agriculture",
tin: "0000000004",
email: "imports@moa.gov.et",
phone: "+251111000004",
profiles: importExport(
4,
"0b1c0004-0000-4000-8000-000000000001",
"0b1c0004-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0005-0000-4000-8000-000000000005",
name: "Ministry of Trade and Regional Integration",
tin: "0000000005",
email: "trade@motri.gov.et",
phone: "+251111000005",
profiles: importExport(
5,
"0b1c0005-0000-4000-8000-000000000001",
"0b1c0005-0000-4000-8000-000000000002",
),
},
{
id: "0a1b0006-0000-4000-8000-000000000006",
name: "Ethiopian Disaster Risk Management Commission",
tin: "0000000006",
email: "relief@edrmc.gov.et",
phone: "+251111000006",
profiles: importExport(
6,
"0b1c0006-0000-4000-8000-000000000001",
"0b1c0006-0000-4000-8000-000000000002",
),
},
];
/** Fallback entity used to backfill legacy government / null-company bookings. */
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { Company } from "../modules/companies/entities/company.entity";
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
import {
GOV_COMPANIES,
GOV_COMPANY_KIND,
GOV_COMPANY_STATUS,
GOV_COMPANY_TYPE,
GOV_PROFILE_STATUS,
} from "./data/gov-companies.data";
/**
* Idempotently seeds the Ethiopian government entities (with importer + exporter
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
*/
@Injectable()
export class GovCompaniesSeeder {
private readonly logger = new Logger(GovCompaniesSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const companyRepo = manager.getRepository(Company);
const profileRepo = manager.getRepository(CompanyProfile);
for (const gov of GOV_COMPANIES) {
const existing = await companyRepo.findOne({ where: { id: gov.id } });
if (!existing) {
await companyRepo.save(
companyRepo.create({
id: gov.id,
name: gov.name,
type: GOV_COMPANY_TYPE,
kind: GOV_COMPANY_KIND,
status: GOV_COMPANY_STATUS,
tin: gov.tin,
country: "Ethiopia",
email: gov.email,
phone: gov.phone,
}),
);
this.logger.log(`Created government company: ${gov.name}`);
}
for (const profile of gov.profiles) {
const existingProfile = await profileRepo.findOne({
where: { id: profile.id },
});
if (existingProfile) continue;
await profileRepo.save(
profileRepo.create({
id: profile.id,
companyId: gov.id,
type: profile.type,
reference: profile.reference,
status: GOV_PROFILE_STATUS,
}),
);
this.logger.log(
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
);
}
}
});
this.logger.log("Government companies seeded.");
}
}

View File

@@ -0,0 +1,474 @@
import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
type DemoDirection = 'IMPORT' | 'EXPORT';
interface DemoTrain {
trainNumber: string;
direction: DemoDirection;
status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED';
bookingPrefix: string;
departureOffsetHours: number;
}
const DEMO_TRAINS: DemoTrain[] = [
{
trainNumber: 'MSH-DEMO-IMP-01',
direction: 'IMPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-IMP-01',
departureOffsetHours: 6,
},
{
trainNumber: 'MSH-DEMO-IMP-02',
direction: 'IMPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-IMP-02',
departureOffsetHours: -3,
},
{
trainNumber: 'MSH-DEMO-IMP-03',
direction: 'IMPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-IMP-03',
departureOffsetHours: -14,
},
{
trainNumber: 'MSH-DEMO-EXP-01',
direction: 'EXPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-EXP-01',
departureOffsetHours: 8,
},
{
trainNumber: 'MSH-DEMO-EXP-02',
direction: 'EXPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-EXP-02',
departureOffsetHours: -2,
},
{
trainNumber: 'MSH-DEMO-EXP-03',
direction: 'EXPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-EXP-03',
departureOffsetHours: -12,
},
];
@Injectable()
export class MarshallingDemoTrainsSeeder {
private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const wagonTypeRepo = this.dataSource.getRepository(WagonType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const djiboutiYard =
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const ethiopiaYard =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ??
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
(await wagonTypeRepo.findOne({ where: { isActive: true } }));
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
const missing = [
!djiboutiYard ? 'Djibouti yard' : '',
!ethiopiaYard ? 'Ethiopia yard' : '',
!serviceType ? 'service type' : '',
!wagonType ? 'wagon type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
return;
}
let seeded = 0;
for (const demo of DEMO_TRAINS) {
const created = await this.seedTrain(demo, {
djiboutiYard: djiboutiYard!,
ethiopiaYard: ethiopiaYard!,
serviceType: serviceType!,
cargoType,
wagonType: wagonType!,
warehouse: warehouse!,
warehouseYard: warehouseYard!,
warehouseZone: warehouseZone!,
});
if (created) seeded += 1;
}
this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`);
} catch (error) {
this.logger.error(
`MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async seedTrain(
demo: DemoTrain,
refs: {
djiboutiYard: Yard;
ethiopiaYard: Yard;
serviceType: ServiceType;
cargoType: CargoType | null;
wagonType: WagonType;
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<boolean> {
const bookingRepo = this.dataSource.getRepository(Booking);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation);
const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existing) {
await this.backfillDispatchQueueInventory(demo, refs);
return false;
}
const now = new Date();
const departure = this.addHours(now, demo.departureOffsetHours);
const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10);
const isDispatched = demo.status === 'DISPATCHED';
const isArrived = demo.status === 'ARRIVED';
const hasDeparted = isDispatched || isArrived;
const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
const locomotive = await this.ensureLocomotive(originYard.id);
const bookingWeights = [22.4, 24.8, 18.6, 20.2];
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: totalWeight,
totalLengthMeters: wagonLength * bookingWeights.length,
wagonCount: bookingWeights.length,
status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard.id,
destinationStationId: destinationYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualDepartureAt: hasDeparted ? departure : null,
actualArrivalAt: isArrived ? arrival : null,
status: demo.status as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: demo.direction,
maxWagons: 53,
bookingWindowStatus: 'CLOSED',
}),
);
for (const [index, weightTons] of bookingWeights.entries()) {
const sequence = index + 1;
const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.save(
bookingRepo.create({
reference: bookingReference,
originYardId: originYard.id,
destinationYardId: destinationYard.id,
serviceTypeId: refs.serviceType.id,
status: hasDeparted ? 'IN_TRANSIT' : 'PAID',
paymentStatus: 'PAID',
scheduledDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: demo.direction,
freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER',
cargoTypeId: refs.cargoType?.id ?? null,
cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`,
cargoTotalWeightVgm: weightTons * 1000,
trainScheduleId: schedule.id,
schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED',
scheduledAt: now,
}),
);
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: weightTons * 1000,
now,
});
const physicalWagon = await this.ensureWagon({
wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`,
wagonTypeId: refs.wagonType.id,
yardId: originYard.id,
trainScheduleId: schedule.id,
tareWeight,
capacityTons: wagonCapacity,
dispatched: hasDeparted,
});
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: trainSet.id,
wagonTypeId: refs.wagonType.id,
physicalWagonId: physicalWagon.id,
sequenceNo: sequence,
capacityTons: wagonCapacity,
lengthMeters: wagonLength,
assignedWeightTons: weightTons,
status: hasDeparted ? 'DEPARTED' : 'LOADED',
}),
);
await this.dataSource.getRepository(Wagon).update(physicalWagon.id, {
trainSetWagonId: trainSetWagon.id,
});
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK',
status: hasDeparted ? 'DEPARTED' : 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
containerNumber,
positionOnWagon: 1,
sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`,
chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`,
grossWeightTons: weightTons,
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
if (demo.direction === 'IMPORT') {
await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted);
}
return true;
}
private async backfillDispatchQueueInventory(
demo: DemoTrain,
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<void> {
const bookingRepo = this.dataSource.getRepository(Booking);
for (let sequence = 1; sequence <= 4; sequence++) {
const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) continue;
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: Number(booking.cargoTotalWeightVgm) || 0,
now: new Date(),
});
}
}
private async ensureDispatchQueueInventory(input: {
booking: Booking;
demo: DemoTrain;
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
};
weightKg: number;
now: Date;
}): Promise<void> {
const repo = this.dataSource.getRepository(WarehouseInventory);
const existing = await repo.findOne({ where: { bookingId: input.booking.id } });
if (existing) return;
const exportDispatch = input.demo.direction === 'EXPORT';
const arrivedAt = this.addHours(input.now, -8);
const inspectedAt = this.addHours(input.now, -6);
const readyAt = this.addHours(input.now, -4);
const loadedAt = this.addHours(input.now, -2);
await repo.save(
repo.create({
warehouseId: input.refs.warehouse.id,
yardId: input.refs.warehouseYard.id,
zoneId: input.refs.warehouseZone.id,
bookingId: input.booking.id,
quantity: 1,
weight: input.weightKg,
status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP',
inspectionStatus: 'PASSED',
arrivedAt,
unloadedAt: exportDispatch ? null : arrivedAt,
inspectedAt,
readyForLoadingAt: exportDispatch ? readyAt : null,
loadedAt: exportDispatch ? loadedAt : null,
readyForPickupAt: exportDispatch ? null : readyAt,
notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`,
}),
);
}
private async ensureLocomotive(currentYardId: string): Promise<Locomotive> {
const repo = this.dataSource.getRepository(Locomotive);
const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } });
if (existing) return existing;
return repo.save(
repo.create({
code: 'MSH-DEMO-LOCO',
name: 'Marshalling Demo Locomotive',
locomotiveType: 'DIESEL',
maxPullWeightTons: 4200,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
currentYardId,
}),
);
}
private async ensureWagon(input: {
wagonNumber: string;
wagonTypeId: string;
yardId: string;
trainScheduleId: string;
tareWeight: number;
capacityTons: number;
dispatched: boolean;
}): Promise<Wagon> {
const repo = this.dataSource.getRepository(Wagon);
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
if (existing) return existing;
return repo.save(
repo.create({
wagonNumber: input.wagonNumber,
wagonTypeId: input.wagonTypeId,
currentYardId: input.yardId,
currentTrainScheduleId: input.trainScheduleId,
tareWeight: input.tareWeight,
maxPayloadWeight: input.capacityTons,
status: WagonStatus.Assigned,
notes: 'Marshalling demo seed wagon',
}),
);
}
private async seedImportOperation(
trainScheduleId: string,
trainNumber: string,
now: Date,
departure: Date,
dispatched: boolean,
): Promise<void> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
await repo.save(
repo.create({
trainScheduleId,
documents: {
DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now),
PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now),
DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now),
ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now),
RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now),
},
gatepassGrantedAt: now,
readyForLoadingAt: now,
loadedOnTrainAt: now,
departedFromDjiboutiAt: dispatched ? departure : null,
performedBy: 'Marshalling Demo Seeder',
notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow',
}),
);
}
private documentRecord(trainNumber: string, type: string, now: Date) {
return {
reference: `${type}-${trainNumber}`,
uploadedAt: now.toISOString(),
uploadedBy: 'Marshalling Demo Seeder',
notes: 'Seeded document for import marshalling workflow',
};
}
private addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
}