diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index df84d0c35..f8b2b27fe 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -24,6 +24,7 @@
"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",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 9026a4dbc..db05ae26f 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
+import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -56,6 +57,7 @@ import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-inte
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
+import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
@@ -79,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
- // EventEmitterModule.forRoot(),
+ EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -144,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
+ GovCompaniesSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
@@ -173,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
+ private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
@@ -199,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
+ // Government entities (with importer/exporter profiles) that government
+ // bookings bill to. Idempotent — keyed by fixed IDs.
+ await this.govCompaniesSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts
new file mode 100644
index 000000000..5c42cad65
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts
@@ -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 (`
__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 {
+ 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 {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
+ await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts
new file mode 100644
index 000000000..95ea3db1b
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts
@@ -0,0 +1,129 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Government bookings now bill to a real seeded government company + an explicit
+ * importer/exporter profile, instead of carrying a null company + free-text
+ * institution. This migration:
+ *
+ * 1. Adds `companies.kind` (commercial | government).
+ * 2. Seeds the Ethiopian government entities + their importer/exporter
+ * profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
+ * 3. Backfills every booking with a NULL company_id / company_profile_id so
+ * the NOT NULL constraints below can be applied:
+ * - NULL company_id → the default government company.
+ * - NULL company_profile_id → the company's profile matching the booking
+ * trade direction; else any profile of the company; else the default
+ * government importer profile.
+ * 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
+ */
+export class AddCompanyKindAndGovBookingLinks1821000000003
+ implements MigrationInterface
+{
+ name = "AddCompanyKindAndGovBookingLinks1821000000003";
+
+ // Mirrors src/seed/data/gov-companies.data.ts
+ private readonly govCompanies = [
+ { id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
+ { id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
+ { id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
+ { id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
+ { id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
+ { id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
+ ];
+
+ private get defaultCompanyId(): string {
+ return this.govCompanies[0].id;
+ }
+ private get defaultImporterProfileId(): string {
+ return this.govCompanies[0].im;
+ }
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // 1. kind column
+ await queryRunner.query(
+ `ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
+ );
+
+ // 2. seed government companies + importer/exporter profiles (idempotent)
+ for (const g of this.govCompanies) {
+ await queryRunner.query(
+ `INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
+ VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
+ ON CONFLICT ("id") DO NOTHING`,
+ [g.id, g.name, g.tin, g.email, g.phone],
+ );
+ await queryRunner.query(
+ `INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
+ VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
+ ON CONFLICT ("id") DO NOTHING`,
+ [g.im, g.id, g.imRef, g.ex, g.exRef],
+ );
+ }
+
+ // 3a. backfill NULL company_id → default government company
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
+ [this.defaultCompanyId],
+ );
+
+ // 3b. backfill NULL company_profile_id → profile matching trade direction
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" b
+ SET "company_profile_id" = cp."id"
+ FROM "freight"."company_profiles" cp
+ WHERE b."company_profile_id" IS NULL
+ AND cp."company_id" = b."company_id"
+ AND cp."deleted_at" IS NULL
+ AND cp."type" = CASE b."trade_direction"
+ WHEN 'IMPORT' THEN 'importer'
+ WHEN 'EXPORT' THEN 'exporter'
+ ELSE NULL END`,
+ );
+
+ // 3c. fallback → any profile of the booking's company
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" b
+ SET "company_profile_id" = (
+ SELECT cp."id" FROM "freight"."company_profiles" cp
+ WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
+ ORDER BY cp."created_at" ASC LIMIT 1)
+ WHERE b."company_profile_id" IS NULL
+ AND EXISTS (
+ SELECT 1 FROM "freight"."company_profiles" cp
+ WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
+ );
+
+ // 3d. final fallback → default government importer profile
+ await queryRunner.query(
+ `UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
+ [this.defaultImporterProfileId],
+ );
+
+ // 4. enforce NOT NULL
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
+ );
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
+ );
+ // Seeded government rows are intentionally left in place.
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts
new file mode 100644
index 000000000..9f0e8e7bd
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts
@@ -0,0 +1,47 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Make the payment projection source-agnostic so any domain (not just bookings)
+ * can own a payment intent.
+ *
+ * - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
+ * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
+ * new domain no longer needs an enum migration to write its intents.
+ * - adds `payments.reference_type varchar(40)` — the gateway reference type
+ * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
+ * path can query the provider without hardcoding it.
+ *
+ * Matches payment/entities/payment.entity.ts.
+ */
+export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
+ name = "MakePaymentsTypeGeneric1821000000004";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
+ );
+ await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
+
+ await queryRunner.query(
+ `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
+ );
+
+ // Restore the single-value enum. Any non-'booking' rows would block the cast;
+ // collapse them first so the down migration is safe.
+ await queryRunner.query(
+ `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
+ );
+ await queryRunner.query(
+ `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts
index 5a801cf73..e954b7e1b 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
@Controller("billing")
@FreightAdmin()
export class BillingController {
- constructor(private readonly billingService: BillingService) {}
+ constructor(private readonly billingService: BillingService) { }
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
@@ -16,9 +16,9 @@ export class BillingController {
return this.billingService.findAll();
}
- @Get("invoices/booking/:bookingId")
- @ApiOperation({ summary: "List invoices for a booking" })
- findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
- return this.billingService.findByBooking(bookingId);
+ @Get("invoices/:id")
+ @ApiOperation({ summary: "Get an invoice with its line items" })
+ findById(@Param("id", ParseUUIDPipe) id: string) {
+ return this.billingService.findById(id);
}
}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts
index 2b16b1515..551fae6bf 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.module.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts
@@ -1,14 +1,24 @@
-import { Module } from "@nestjs/common";
+import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
+import { PortalBillingController } from "./portal-billing.controller";
import { BillingService } from "./billing.service";
import { Invoice } from "./entities/invoice.entity";
+import { InvoiceLine } from "./entities/invoice-line.entity";
+import { InvoiceRepository } from "./invoice.repository";
+import { InvoiceLineRepository } from "./invoice-line.repository";
+import { PaymentModule } from "../payment/payment.module";
+import { CompaniesModule } from "../companies/companies.module";
@Module({
- imports: [TypeOrmModule.forFeature([Invoice])],
- controllers: [BillingController],
- providers: [BillingService],
+ imports: [
+ TypeOrmModule.forFeature([Invoice, InvoiceLine]),
+ forwardRef(() => PaymentModule),
+ CompaniesModule,
+ ],
+ controllers: [BillingController, PortalBillingController],
+ providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})
export class BillingModule {}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
new file mode 100644
index 000000000..0e6d97de0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
@@ -0,0 +1,250 @@
+import { Freight } from "@edr/types";
+
+import { BillingService } from "./billing.service";
+
+/**
+ * Minimal in-memory EntityManager stand-in covering the methods
+ * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
+ */
+function makeManager(savedLines: unknown[]) {
+ return {
+ create: (_entity: unknown, data: Record) => data,
+ save: (data: Record) => {
+ const row = { id: data.id ?? "gen-1", ...data };
+ if (data.invoiceId) savedLines.push(row);
+ return Promise.resolve(row);
+ },
+ query: () => Promise.resolve([{ seq: 0 }]),
+ update: jest.fn().mockResolvedValue(undefined),
+ findOne: jest.fn().mockResolvedValue(null),
+ };
+}
+
+function makeEvents() {
+ return { emit: jest.fn() };
+}
+
+function generateInput(overrides: Record = {}) {
+ return {
+ source: Freight.InvoiceSource.Booking,
+ sourceId: "booking-1",
+ type: "prepaid",
+ companyId: "company-1",
+ companyProfileId: "profile-1",
+ currency: "ETB",
+ lines: [
+ {
+ chargeType: "RAIL_FREIGHT",
+ description: "Rail freight",
+ quantity: 2,
+ unitRate: 500,
+ amount: 1000,
+ },
+ {
+ chargeType: "HAZARD_SURCHARGE",
+ description: "Hazard surcharge",
+ quantity: 2,
+ unitRate: 250,
+ amount: 500,
+ },
+ ],
+ ...overrides,
+ };
+}
+
+describe("BillingService.generateInvoice", () => {
+ let savedLines: unknown[];
+ let manager: ReturnType;
+ let events: ReturnType;
+ let dataSource: { transaction: jest.Mock; manager: unknown };
+ let service: BillingService;
+
+ beforeEach(() => {
+ savedLines = [];
+ manager = makeManager(savedLines);
+ events = makeEvents();
+ dataSource = {
+ transaction: jest
+ .fn()
+ .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
+ manager,
+ };
+ service = new BillingService(
+ dataSource as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+ });
+
+ it("creates a PENDING invoice with one line per input line", async () => {
+ const invoice = await service.generateInvoice(generateInput());
+
+ expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
+ expect(invoice.companyId).toBe("company-1");
+ expect(invoice.source).toBe("booking");
+ expect(invoice.sourceId).toBe("booking-1");
+ expect(invoice.totalAmount).toBe(1500);
+ expect(invoice.issuedAt).toBeInstanceOf(Date);
+ expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
+ expect(savedLines).toHaveLength(2);
+ });
+
+ it("sums line amounts when no explicit totalAmount is given", async () => {
+ const invoice = await service.generateInvoice(
+ generateInput({ totalAmount: undefined }),
+ );
+ expect(invoice.totalAmount).toBe(1500);
+ });
+
+ it("leaves issuedAt null for a DRAFT invoice", async () => {
+ const invoice = await service.generateInvoice(
+ generateInput({ status: Freight.InvoiceStatus.Draft }),
+ );
+ expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
+ expect(invoice.issuedAt).toBeNull();
+ });
+
+ it("enlists in a caller's transaction when a manager is passed", async () => {
+ await service.generateInvoice(generateInput(), manager as never);
+ expect(dataSource.transaction).not.toHaveBeenCalled();
+ expect(savedLines).toHaveLength(2);
+ });
+});
+
+describe("BillingService.markInvoiceAsPaid", () => {
+ it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
+ const open = {
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Pending,
+ source: "booking",
+ sourceId: "booking-1",
+ };
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(open),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
+
+ expect(mg.update).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: "inv-1" },
+ { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
+ );
+ expect(events.emit).toHaveBeenCalledWith(
+ "booking.invoice.paid",
+ expect.objectContaining({
+ invoiceId: "inv-1",
+ status: Freight.InvoiceStatus.Paid,
+ paymentId: "pay-1",
+ }),
+ );
+ });
+
+ it("is a no-op (no event) when the invoice is already paid", async () => {
+ const paid = {
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Paid,
+ source: "booking",
+ };
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(paid),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
+
+ expect(mg.update).not.toHaveBeenCalled();
+ expect(events.emit).not.toHaveBeenCalled();
+ });
+});
+
+describe("BillingService.settlePayable", () => {
+ it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
+ const open = {
+ id: "inv-1",
+ status: Freight.InvoiceStatus.Pending,
+ source: Freight.InvoiceSource.Booking,
+ sourceId: "booking-1",
+ };
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(open),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ const settled = await service.settlePayable(
+ Freight.InvoiceSource.Booking,
+ "booking-1",
+ "pay-1",
+ mg as never,
+ );
+
+ expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
+ expect(mg.update).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: "inv-1" },
+ { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
+ );
+ expect(events.emit).toHaveBeenCalledWith(
+ "booking.invoice.paid",
+ expect.anything(),
+ );
+ });
+
+ it("is a no-op (returns null) when the source has no open invoice", async () => {
+ const mg = {
+ findOne: jest.fn().mockResolvedValue(null),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const events = makeEvents();
+ const service = new BillingService(
+ { manager: mg } as never,
+ {} as never,
+ {} as never,
+ events as never,
+ {} as never, // payment
+ {} as never, // companies
+ );
+
+ const settled = await service.settlePayable(
+ Freight.InvoiceSource.Booking,
+ "booking-1",
+ "pay-1",
+ mg as never,
+ );
+
+ expect(settled).toBeNull();
+ expect(mg.update).not.toHaveBeenCalled();
+ expect(events.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 39eae6ef5..01b057a76 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -1,26 +1,542 @@
-import { Injectable } from "@nestjs/common";
-import { InjectRepository } from "@nestjs/typeorm";
-import { Repository } from "typeorm";
+import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import { Freight, PaymentReferenceType } from "@edr/types";
+import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
+import { InvoiceLine } from "./entities/invoice-line.entity";
+import { InvoiceRepository } from "./invoice.repository";
+import { InvoiceLineRepository } from "./invoice-line.repository";
+import { PaymentService } from "../payment/payment.service";
+import { InitiateResponseDto } from "../payment/payments.dto";
+import { CompaniesService } from "../companies/companies.service";
+
+/** Options forwarded to the payment gateway when settling an invoice. */
+export interface PayInvoiceOptions {
+ method?: string;
+ platform?: "web" | "mobile";
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+}
+
+/** Default invoice payment-term window, in days, used to compute `dueAt`. */
+const DEFAULT_DUE_DAYS = 14;
+
+/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
+const OPEN_STATUSES: Freight.InvoiceStatus[] = [
+ Freight.InvoiceStatus.Draft,
+ Freight.InvoiceStatus.Pending,
+ Freight.InvoiceStatus.Overdue,
+];
+
+/** A single line to bill on a generated invoice. */
+export interface InvoiceLineInput {
+ chargeType: string;
+ description?: string;
+ /** Units this line bills for; defaults to 1. */
+ quantity?: number;
+ /** Price per unit; defaults to 0. */
+ unitRate?: number;
+ /** Line total; defaults to `quantity * unitRate`. */
+ amount?: number;
+ currency?: string;
+ metadata?: Record | null;
+}
+
+/** Everything needed to generate an invoice for any source. */
+export interface GenerateInvoiceInput {
+ /** Originating subsystem; namespaces events (`${source}.invoice.`). */
+ source: Freight.InvoiceSource;
+ /** Identifier of the source record (e.g. booking id). */
+ sourceId: string;
+ /** What the invoice is for (e.g. "prepaid", "credit"). */
+ type: string;
+ companyId: string;
+ companyProfileId: string;
+ lines: InvoiceLineInput[];
+ currency?: string;
+ /** Explicit total; defaults to the sum of line amounts. */
+ totalAmount?: number;
+ /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
+ dueAt?: Date;
+ dueInDays?: number;
+ /**
+ * Initial status. DRAFT leaves `issuedAt` null; any issued status
+ * (default PENDING) stamps `issuedAt`.
+ */
+ status?: Freight.InvoiceStatus;
+}
+
+/** Payload broadcast on `${source}.invoice.`. */
+export interface InvoiceEventPayload {
+ invoiceId: string;
+ invoiceNumber: string;
+ source: Freight.InvoiceSource;
+ sourceId: string;
+ type: string;
+ companyId: string;
+ companyProfileId: string;
+ totalAmount: number;
+ currency: string;
+ status: Freight.InvoiceStatus;
+ paymentId?: string | null;
+}
@Injectable()
export class BillingService {
+ private readonly logger = new Logger(BillingService.name);
+
constructor(
- @InjectRepository(Invoice)
- private readonly invoicesRepository: Repository,
- ) {}
+ private readonly dataSource: DataSource,
+ private readonly invoices: InvoiceRepository,
+ private readonly invoiceLines: InvoiceLineRepository,
+ private readonly events: EventEmitter2,
+ @Inject(forwardRef(() => PaymentService))
+ private readonly payment: PaymentService,
+ private readonly companies: CompaniesService,
+ ) { }
+
+ // ── Reads ──────────────────────────────────────────────────────────────────
/** List every invoice (most recent first). */
findAll(): Promise {
- return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
+ return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
- /** List invoices for a given booking. */
- findByBooking(bookingId: string): Promise {
- return this.invoicesRepository.find({
- where: { bookingId },
+ /** Invoice header plus its line items. */
+ async findById(id: string): Promise {
+ const invoice = await this.invoices.findById(id);
+ if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
+ const lines = await this.invoiceLines.findAll({
+ where: { invoiceId: id },
+ order: { createdAt: "ASC" },
+ });
+ return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
+ }
+
+ // ── Customer-scoped reads (portal) ───────────────────────────────────────────
+
+ /** Resolve the customer's company id from their IAM user id (null if none). */
+ async resolveCompanyId(userId: string): Promise {
+ try {
+ const { company } = await this.companies.getCompanyInfoByUserId(userId);
+ return company?.id ?? null;
+ } catch {
+ return null;
+ }
+ }
+
+ /** Every invoice billed to a company, newest first, with billing relations. */
+ findByCompany(companyId: string): Promise {
+ return this.invoices.findAll({
+ where: { companyId },
+ relations: { company: true, companyProfile: true },
+ order: { createdAt: "DESC" },
+ });
+ }
+
+ /** Invoices for the signed-in customer; empty when they have no company. */
+ async findForUser(userId: string): Promise {
+ const companyId = await this.resolveCompanyId(userId);
+ return companyId ? this.findByCompany(companyId) : [];
+ }
+
+ /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
+ async findByIdForUser(
+ id: string,
+ userId: string,
+ ): Promise {
+ const companyId = await this.resolveCompanyId(userId);
+ const invoice = await this.findById(id);
+ if (!companyId || invoice.companyId !== companyId) {
+ throw new NotFoundException(`Invoice ${id} not found`);
+ }
+ return invoice;
+ }
+
+ /**
+ * Initiate gateway payment for one of the customer's own invoices. Verifies
+ * ownership, then charges whichever open invoice the source currently has
+ * (see {@link payInvoice}).
+ */
+ async payInvoiceForUser(
+ id: string,
+ userId: string,
+ opts: PayInvoiceOptions = {},
+ ): Promise {
+ const invoice = await this.findByIdForUser(id, userId);
+ return this.payInvoice(
+ invoice.source as Freight.InvoiceSource,
+ invoice.sourceId,
+ opts,
+ );
+ }
+
+ // ── Generation ───────────────────────────────────────────────────────────────
+
+ /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
+ private async nextInvoiceNumber(mg: EntityManager): Promise {
+ const now = new Date();
+ const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
+ const prefix = `FRT-${ymd}-`;
+ const [row] = await mg.query(
+ `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
+ FROM freight.invoices WHERE invoice_number LIKE $1`,
+ [`${prefix}%`],
+ );
+ const next = Number(row?.seq ?? 0) + 1;
+ return `${prefix}${String(next).padStart(5, "0")}`;
+ }
+
+ /**
+ * Generate an invoice for any source (booking, demurrage, manual, …).
+ *
+ * Persists the header plus its lines in one transaction and assigns the next
+ * sequential `invoice_number`. The total defaults to the sum of line amounts
+ * unless `totalAmount` is given. Issued invoices (default PENDING) stamp
+ * `issuedAt`; pass `status: DRAFT` to leave it unissued.
+ *
+ * Pass `manager` to enlist in a caller's transaction (e.g. when generating an
+ * invoice as part of a larger booking flow).
+ */
+ async generateInvoice(
+ input: GenerateInvoiceInput,
+ manager?: EntityManager,
+ ): Promise {
+ const run = (mg: EntityManager) => this.createInvoice(input, mg);
+ return manager ? run(manager) : this.dataSource.transaction(run);
+ }
+
+ private async createInvoice(
+ input: GenerateInvoiceInput,
+ mg: EntityManager,
+ ): Promise {
+ const currency = input.currency ?? "ETB";
+ const status = input.status ?? Freight.InvoiceStatus.Pending;
+ const issued = status !== Freight.InvoiceStatus.Draft;
+
+ const lines = input.lines.map((l) => {
+ const quantity = l.quantity ?? 1;
+ const unitRate = l.unitRate ?? 0;
+ return {
+ chargeType: l.chargeType,
+ description: l.description,
+ quantity,
+ unitRate,
+ amount: l.amount ?? quantity * unitRate,
+ currency: l.currency ?? currency,
+ metadata: l.metadata ?? null,
+ };
+ });
+
+ const totalAmount =
+ input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
+
+ const dueAt =
+ input.dueAt ??
+ new Date(
+ Date.now() +
+ (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
+ );
+
+ const invoiceNumber = await this.nextInvoiceNumber(mg);
+
+ const invoice = await mg.save(
+ mg.create(Invoice, {
+ invoiceNumber,
+ source: input.source,
+ sourceId: input.sourceId,
+ type: input.type,
+ companyId: input.companyId,
+ companyProfileId: input.companyProfileId,
+ totalAmount,
+ currency,
+ status,
+ issuedAt: issued ? new Date() : null,
+ dueAt,
+ }),
+ );
+
+ const savedLines = await Promise.all(
+ lines.map((l) =>
+ mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
+ ),
+ );
+
+ this.logger.log(
+ `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
+ );
+
+ return { ...invoice, lines: savedLines };
+ }
+
+ // ── State transitions ────────────────────────────────────────────────────────
+
+ /**
+ * Mark an invoice paid and link the gateway payment, then emit
+ * `${source}.invoice.paid`. Full-payment only — no partial settlement.
+ * No-op when the invoice is already paid. Pass `manager` to enlist in a
+ * caller's transaction.
+ */
+ async markInvoiceAsPaid(
+ invoiceId: string,
+ paymentId: string | null = null,
+ manager?: EntityManager,
+ ): Promise {
+ return this.transition(
+ invoiceId,
+ Freight.InvoiceStatus.Paid,
+ "paid",
+ { paymentId: paymentId ?? undefined },
+ manager,
+ );
+ }
+
+ /**
+ * Mark an invoice refunded and emit `${source}.invoice.refunded`.
+ * No-op when already refunded.
+ */
+ async markInvoiceAsRefunded(
+ invoiceId: string,
+ manager?: EntityManager,
+ ): Promise {
+ return this.transition(
+ invoiceId,
+ Freight.InvoiceStatus.Refunded,
+ "refunded",
+ {},
+ manager,
+ );
+ }
+
+ /**
+ * Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
+ * No-op when already cancelled.
+ */
+ async cancelInvoice(
+ invoiceId: string,
+ manager?: EntityManager,
+ ): Promise {
+ return this.transition(
+ invoiceId,
+ Freight.InvoiceStatus.Cancelled,
+ "cancelled",
+ {},
+ manager,
+ );
+ }
+
+ /**
+ * Load the invoice, apply the new status (+ extra columns), then emit
+ * `${source}.invoice.`. No-op (returns the invoice) when it is already
+ * in the target status. Throws when the invoice does not exist.
+ *
+ * Note: the event fires in-process synchronously. When a `manager` from an
+ * outer transaction is passed, listeners run before that transaction commits.
+ */
+ private async transition(
+ invoiceId: string,
+ status: Freight.InvoiceStatus,
+ event: string,
+ extra: { paymentId?: string },
+ manager?: EntityManager,
+ ): Promise {
+ const mg = manager ?? this.dataSource.manager;
+ const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
+ if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
+ if (invoice.status === status) return invoice;
+
+ await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
+
+ const updated = { ...invoice, ...extra, status } as Invoice;
+ this.emitInvoiceEvent(event, updated);
+ return updated;
+ }
+
+ /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */
+ private emitInvoiceEvent(event: string, invoice: Invoice): void {
+ const payload: InvoiceEventPayload = {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoiceNumber,
+ source: invoice.source as Freight.InvoiceSource,
+ sourceId: invoice.sourceId,
+ type: invoice.type,
+ companyId: invoice.companyId,
+ companyProfileId: invoice.companyProfileId,
+ totalAmount: invoice.totalAmount,
+ currency: invoice.currency,
+ status: invoice.status,
+ paymentId: invoice.paymentId ?? null,
+ };
+ this.events.emit(`${invoice.source}.invoice.${event}`, payload);
+ }
+
+ // ── Payment reconciliation (by source) ───────────────────────────────────────
+
+ /**
+ * The invoice a gateway payment should settle for a source record, or null if
+ * none. This is the billing document of record for "what is owed" — callers
+ * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
+ * recomputing from the source's own total, so discounts/penalties/adjustments
+ * carried on the invoice are honored.
+ *
+ * Pass `type` to select a specific invoice when a source carries several (e.g.
+ * a booking's up-front vs final charge); omit it to settle whichever single
+ * invoice is currently open. Returns the most recent matching open (unpaid,
+ * non-cancelled) invoice.
+ */
+ findPayable(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ type?: string,
+ ): Promise {
+ return this.dataSource.getRepository(Invoice).findOne({
+ where: {
+ source,
+ sourceId,
+ status: In(OPEN_STATUSES),
+ ...(type ? { type } : {}),
+ },
order: { issuedAt: "DESC" },
});
}
+
+ /**
+ * Settle a source's currently-open invoice as paid and link the gateway
+ * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
+ * delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
+ * settlement. No-op (returns null) when the source has no open invoice.
+ *
+ * Type-blind by design: settles whichever invoice is due; any per-type reaction
+ * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
+ * Pass the caller's transaction `manager` to enlist in its DB transaction.
+ *
+ * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
+ * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
+ * for callers that settle by source rather than by gateway intent id.
+ */
+ async settlePayable(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ paymentId: string | null,
+ manager?: EntityManager,
+ ): Promise {
+ const mg = manager ?? this.dataSource.manager;
+ const invoice = await mg.findOne(Invoice, {
+ where: { source, sourceId, status: In(OPEN_STATUSES) },
+ order: { issuedAt: "DESC" },
+ });
+ if (!invoice) return null;
+
+ return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
+ }
+
+ /**
+ * Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
+ * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
+ * No-op (returns null) when the source has no paid invoice.
+ *
+ * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
+ * to enlist in its DB transaction.
+ */
+ async refundPayable(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ manager?: EntityManager,
+ ): Promise {
+ const mg = manager ?? this.dataSource.manager;
+ const invoice = await mg.findOne(Invoice, {
+ where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
+ order: { issuedAt: "DESC" },
+ });
+ if (!invoice) return null;
+
+ return this.markInvoiceAsRefunded(invoice.id, mg);
+ }
+
+ // ── Payment initiation & settlement (the gateway boundary) ───────────────────
+
+ /**
+ * Charge a source's open invoice through the payment gateway. Billing is the
+ * single place that turns "what is owed" (the invoice) into a payment intent —
+ * the domain never talks to the payment service directly. Resolves the open
+ * invoice, opens an intent for `invoice.totalAmount`, records the intent id on
+ * the invoice (the settlement correlation key), and returns the client action.
+ *
+ * When the provider settles synchronously, the invoice is settled inline here —
+ * after the intent id is stored — so the `payment.succeeded` correlation can
+ * never fire before the link exists. Throws when the source has no open invoice.
+ */
+ async payInvoice(
+ source: Freight.InvoiceSource,
+ sourceId: string,
+ opts: {
+ method?: string;
+ platform?: "web" | "mobile";
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+ } = {},
+ ): Promise {
+ const invoice = await this.findPayable(source, sourceId);
+ if (!invoice) {
+ throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
+ }
+
+ const result = await this.payment.initiate({
+ referenceId: sourceId,
+ source: invoice.source,
+ // Gateway reference type derives from the invoice source by convention
+ // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
+ // the domain never supplies it. New sources add their uppercased value to
+ // the PaymentReferenceType enum.
+ referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
+ orderRef: invoice.invoiceNumber,
+ amountMinor: Math.round(Number(invoice.totalAmount)),
+ currency: invoice.currency,
+ reason: `Payment for invoice ${invoice.invoiceNumber}`,
+ method: opts.method ?? "TELEBIRR",
+ platform: opts.platform,
+ payerAccount: opts.payerAccount,
+ returnUrl: opts.returnUrl,
+ failureUrl: opts.failureUrl,
+ });
+
+ // Link the intent to the invoice BEFORE any settlement can correlate against it.
+ await this.dataSource
+ .getRepository(Invoice)
+ .update({ id: invoice.id }, { paymentId: result.intentId });
+
+ if (result.immediateSuccess) {
+ await this.settleByPaymentId(
+ result.intentId,
+ result.providerTxnId,
+ result.paidAt,
+ );
+ }
+
+ return result.response;
+ }
+
+ /**
+ * Settle the open invoice linked to a gateway intent id, if any. Called by the
+ * payment service when an intent succeeds: finds the invoice linked by
+ * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
+ * to advance on. Idempotent — no-op when no open invoice is linked (already
+ * settled, or settled inline by {@link payInvoice}).
+ */
+ async settleByPaymentId(
+ paymentId: string,
+ _providerTxnId?: string,
+ _paidAt?: Date,
+ ): Promise {
+ const invoice = await this.dataSource.getRepository(Invoice).findOne({
+ where: { paymentId, status: In(OPEN_STATUSES) },
+ order: { issuedAt: "DESC" },
+ });
+ if (!invoice) return null;
+
+ return this.markInvoiceAsPaid(invoice.id, paymentId);
+ }
}
diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts
new file mode 100644
index 000000000..c29160ab7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts
@@ -0,0 +1,30 @@
+import { ApiPropertyOptional } from "@nestjs/swagger";
+import { IsIn, IsOptional, IsString } from "class-validator";
+
+/** Gateway options for paying an invoice from the customer portal. */
+export class PayInvoiceDto {
+ @ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
+ @IsOptional()
+ @IsString()
+ method?: string;
+
+ @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
+ @IsOptional()
+ @IsIn(["web", "mobile"])
+ platform?: "web" | "mobile";
+
+ @ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
+ @IsOptional()
+ @IsString()
+ payerAccount?: string;
+
+ @ApiPropertyOptional({ description: "Browser redirect URL on success." })
+ @IsOptional()
+ @IsString()
+ returnUrl?: string;
+
+ @ApiPropertyOptional({ description: "Browser redirect URL on failure." })
+ @IsOptional()
+ @IsString()
+ failureUrl?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts
new file mode 100644
index 000000000..a042dedb7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts
@@ -0,0 +1,43 @@
+import { BaseEntity } from "@edr/api-common";
+import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
+
+import { Invoice } from "./invoice.entity";
+
+@Entity({ schema: "freight", name: "invoice_lines" })
+export class InvoiceLine extends BaseEntity {
+ @Column({ name: "invoice_id", type: "uuid", nullable: false })
+ invoiceId!: string;
+
+ @ManyToOne(() => Invoice, { onDelete: "CASCADE" })
+ @JoinColumn({ name: "invoice_id" })
+ invoice!: Invoice;
+
+ @Column({ name: "charge_type", type: "varchar", nullable: false })
+ chargeType!: string;
+
+ @Column({ name: "description", type: "varchar", length: 255, nullable: true })
+ description?: string;
+
+ /** Units this line bills for (e.g. container count, wagon count, tons). */
+ @Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
+ quantity!: number;
+
+ /** Price per unit; `amount` is normally `quantity * unitRate`. */
+ @Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
+ unitRate!: number;
+
+ @Column({
+ name: "amount",
+ type: "numeric",
+ precision: 14,
+ scale: 2,
+ nullable: false,
+ })
+ amount!: number;
+
+ @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
+ currency!: string;
+
+ @Column({ name: "metadata", type: "jsonb", nullable: true })
+ metadata?: Record | null;
+}
diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts
index e2a6f7cc2..61bc9c16b 100644
--- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts
+++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts
@@ -1,17 +1,35 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
-import { Column, Entity } from "typeorm";
+import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
+import { PaymentEntity } from "../../payment/entities/payment.entity";
+import { Company } from "../../companies/entities/company.entity";
+import { CompanyProfile } from "../../companies/entities/company-profile.entity";
-@Entity({schema:"freight", name: "invoices" })
+@Entity({ schema: "freight", name: "invoices" })
+@Index(["companyId"])
+@Index(["companyProfileId"])
export class Invoice extends BaseEntity {
- @Column({ name: "booking_id", type: "uuid" })
- bookingId!: string;
-
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
invoiceNumber!: string;
- @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
- amount!: number;
+ /** The customer (company) this invoice is billed to. */
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ @ManyToOne(() => Company)
+ @JoinColumn({ name: "company_id" })
+ company?: Company;
+
+ /** The specific company profile (importer/exporter/forwarder/...) billed. */
+ @Column({ name: "company_profile_id", type: "uuid" })
+ companyProfileId!: string;
+
+ @ManyToOne(() => CompanyProfile)
+ @JoinColumn({ name: "company_profile_id" })
+ companyProfile?: CompanyProfile;
+
+ @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
+ totalAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
@Column({
name: "status",
type: "enum",
- enum: Freight.PaymentStatus,
- default: Freight.PaymentStatus.Pending,
+ enum: Freight.InvoiceStatus,
+ default: Freight.InvoiceStatus.Draft,
})
- status!: Freight.PaymentStatus;
+ status!: Freight.InvoiceStatus;
- @Column({ name: "issued_at", type: "timestamptz" })
- issuedAt!: Date;
+ /** The source of the payment (e.g. booking, customer, etc.). */
+ @Column({ name: "source", type: "varchar", length: 255, nullable: false })
+ source!: string;
+
+ /** The ID of the source (e.g. booking ID, customer ID, etc.). */
+ @Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
+ sourceId!: string;
+
+ /** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
+ @Column({
+ type: "varchar",
+ length: 255,
+ nullable: false,
+ })
+ type!: string;
+
+ /** Set when the invoice is actually issued (DRAFT invoices leave this null). */
+ @Column({ name: "issued_at", type: "timestamptz", nullable: true })
+ issuedAt?: Date | null;
+
+ /** The ID of the payment that generated this invoice. */
+ @Column({ name: "payment_id", type: "uuid", nullable: true })
+ paymentId?: string | null;
+
+ @ManyToOne(() => PaymentEntity)
+ @JoinColumn({ name: "payment_id" })
+ payment?: PaymentEntity;
@Column({ name: "due_at", type: "timestamptz" })
dueAt!: Date;
diff --git a/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts
new file mode 100644
index 000000000..6a5482543
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from "@edr/api-common";
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { InvoiceLine } from "./entities/invoice-line.entity";
+
+@Injectable()
+export class InvoiceLineRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(InvoiceLine) repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/invoice.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts
new file mode 100644
index 000000000..cc2e89df7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from "@edr/api-common";
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { Invoice } from "./entities/invoice.entity";
+
+@Injectable()
+export class InvoiceRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Invoice) repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts
new file mode 100644
index 000000000..5a007c320
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts
@@ -0,0 +1,60 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+} from "@nestjs/common";
+import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
+import { CurrentUser } from "@edr/api-common";
+
+import {
+ type AuthUserPayload,
+ resolveAuthUserId,
+} from "../../common/resolve-auth-user-id";
+import { BillingService } from "./billing.service";
+import { PayInvoiceDto } from "./dto/pay-invoice.dto";
+
+/**
+ * Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
+ * org-wide), every route here is force-scoped to the signed-in customer's
+ * company — they only ever see and pay their own invoices.
+ */
+@ApiTags("billing")
+@ApiBearerAuth()
+@Controller("billing")
+export class PortalBillingController {
+ constructor(private readonly billingService: BillingService) {}
+
+ @Get("my-invoices")
+ @ApiOperation({ summary: "List the signed-in customer's invoices" })
+ findMine(@CurrentUser() user: AuthUserPayload) {
+ return this.billingService.findForUser(resolveAuthUserId(user));
+ }
+
+ @Get("my-invoices/:id")
+ @ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
+ findMineById(
+ @Param("id", ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
+ }
+
+ @Post("my-invoices/:id/pay")
+ @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
+ pay(
+ @Param("id", ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ @Body() dto: PayInvoiceDto,
+ ) {
+ return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
+ method: dto.method,
+ platform: dto.platform ?? "web",
+ payerAccount: dto.payerAccount,
+ returnUrl: dto.returnUrl,
+ failureUrl: dto.failureUrl,
+ });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
new file mode 100644
index 000000000..47338f196
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts
new file mode 100644
index 000000000..ae01ebc36
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts
@@ -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 {
+ 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, """);
+ return `
+
+
+
+
+ Redirecting to payment…
+
+
+
+
+
+
+`;
+ }
+
+ private buildStatusHtml(status: string, intentId: string): string {
+ return `
+
+
+
+ Payment status
+
+
+
+
+
${status}
+
Intent: ${intentId}
+
+
+`;
+ }
+
+ private buildErrorHtml(message: string): string {
+ return `
+
+
+
+ Payment error
+
+
+
+
+
Payment could not be initiated
+
${message}
+
+
+`;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
index 21473eeb8..1fbe34e1f 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
@@ -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 ?? '') : '',
};
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
index 676d1b5d4..a9806f1f7 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
@@ -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
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
index f9c7e182f..ff08784a6 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
@@ -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,
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
index 0e3133311..cc2288963 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
@@ -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,
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index b1d4192a5..2ebceeabc 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -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 {
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index b959677bd..0d5c52ee0 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -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';
@@ -35,7 +39,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({
@@ -51,7 +54,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingContractSignature,
BookingContainerAllocation,
]),
- PaymentModule,
+ BillingModule,
+ forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
@@ -66,7 +70,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
config.get('app.cbeExchange') ?? {},
}),
],
- controllers: [BookingsController, BookingAllocationController, PayController],
+ controllers: [BookingsController, PayController, BookingPaymentController],
providers: [
BookingsService,
BookingsRepository,
@@ -75,6 +79,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingPricingService,
BookingTransitionService,
BookingContractService,
+ BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
@@ -82,6 +87,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
- exports: [BookingsService, BookingsRepository, BookingPricingService],
+ exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
})
export class BookingsModule {}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index ea7eea31f..a4636cbf7 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -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';
@@ -308,10 +308,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(
@@ -384,7 +397,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 {
@@ -414,6 +436,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)
@@ -444,10 +476,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,
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
index 677ef03fd..9380faba5 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
@@ -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()
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 3c93b26f2..19aa3a199 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -106,8 +106,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' })
@@ -117,11 +119,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' })
diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts
index b31f2939d..15ca85c73 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository {
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 {
qb.andWhere('company.type = :type', { type });
}
+ if (kind) {
+ qb.andWhere('company.kind = :kind', { kind });
+ }
+
if (status) {
qb.andWhere('company.status = :status', { status });
}
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index a838495d5..02f77b2e0 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -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 {
+ 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 }> {
diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts
index c92592286..4dbb932cb 100644
--- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts
+++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts
@@ -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))
diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
index 6702f9f7c..5fe3a3f67 100644
--- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
+++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts
@@ -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",
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index 808fd8a63..b077930b7 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -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 };
}
diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts
index 2bb81a331..5c4a4f7f7 100644
--- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts
+++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts
@@ -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
diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts
index f1f34c3b1..b1b269665 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts
@@ -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, """);
- return `
-
-
-
-
- Redirecting to payment…
-
-
-
-
-
-
-`;
- }
-
- private buildStatusHtml(status: string, intentId: string): string {
- return `
-
-
-
- Payment status
-
-
-
-
-
${status}
-
Intent: ${intentId}
-
-
-`;
- }
-
- private buildErrorHtml(message: string): string {
- return `
-
-
-
- Payment error
-
-
-
-
-
Payment could not be initiated
-
${message}
-
-
-`;
- }
}
diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts
index c48f1fac9..330521218 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.module.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts
@@ -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,
diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts
index a2f0aad2f..71371989b 100644
--- a/apps/edr-freight-api/src/modules/payment/payment.service.ts
+++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts
@@ -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 = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -45,6 +77,23 @@ const STATUS_MAP: Record = {
"refunded": ProviderPaymentStatus.CANCELLED,
};
+const PROVIDER_TO_METHOD: Record = {
+ 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,69 +188,74 @@ export class PaymentService {
};
}
- async initiatePayment(dto: InitiatePaymentDto): Promise {
- 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 {
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",
});
- await this.datasource.getRepository(Booking).update(
- { id: dto.bookingId },
- { paymentStatus: "PAID", status: "PAID" },
- );
- 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 {
- const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
+ const existing = await this.paymentRepo.findOneBy({
+ refId: input.referenceId,
+ });
- const PROVIDER_TO_METHOD: Record = {
- 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 | undefined;
+ const clientAction = (snapshot.clientAction ?? undefined) as
+ | Record
+ | undefined;
const data = {
status,
method,
@@ -220,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,
clientAction: clientAction ?? {},
...data,
} as any);
}
- async getIntentByBookingId(bookingId: string): Promise {
- 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 {
+ 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`,
);
}
@@ -251,75 +310,54 @@ 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");
- }
+ /**
+ * 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 };
- 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" });
- });
+ const paidAt = opts.paidAt ?? new Date();
+ await this.paymentRepo.update(
+ { id: intent.id },
+ { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
+ );
- 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 });
- // if (!intent) throw new NotFoundException("PaymentIntent not found");
- // if (intent.status === "success") return { alreadyFinalized: true };
-
- // 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 },
- // {id:input.intentId},
- // { 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 };
@@ -338,6 +376,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 {
@@ -366,7 +427,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 {
@@ -401,46 +462,34 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
- const { alreadyFinalized } = await this.finalizePaymentSuccess({
- intentId:event.intentId,
- bookingId: event.referenceId,
+ if (event.eventType === "payment.succeeded") {
+ const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
+ if (!intent) {
+ return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
+ }
+ 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 booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
- // console.log(`Received payment event: ${JSON.stringify(event)}`);
- // if (event.eventType === "payment.succeeded") {
- // console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
- // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
- // if (!intent) {
- // return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
- // }
- // console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
- // const { alreadyFinalized } = await this.finalizePaymentSuccess({
- // intentId: intent.id,
- // bookingId: event.referenceId,
- // providerTxnId: event.providerTxnId,
- // paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
- // });
- // 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" });
- // if (!intent) {
- // return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
- // }
- // await this.markPaymentFailed({
- // intentId: intent.id,
- // failureCode: event.failureCode,
- // failureMessage: event.failureMessage,
- // });
- // return { processed: true };
- // }
+ if (event.eventType === "payment.failed") {
+ const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
+ if (!intent) {
+ return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
+ }
+ await this.markPaymentFailed({
+ intentId: intent.id,
+ failureCode: event.failureCode,
+ failureMessage: event.failureMessage,
+ });
+ return { processed: true };
+ }
- // return { processed: false, reason: `Unknown event type: ${event.eventType}` };
+ return { processed: false, reason: `Unknown event type: ${event.eventType}` };
}
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
diff --git a/apps/edr-freight-api/src/scripts/seed-gov-companies.ts b/apps/edr-freight-api/src/scripts/seed-gov-companies.ts
new file mode 100644
index 000000000..41c027905
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/seed-gov-companies.ts
@@ -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);
+});
diff --git a/apps/edr-freight-api/src/seed/data/gov-companies.data.ts b/apps/edr-freight-api/src/seed/data/gov-companies.data.ts
new file mode 100644
index 000000000..841441147
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/data/gov-companies.data.ts
@@ -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];
diff --git a/apps/edr-freight-api/src/seed/gov-companies.seeder.ts b/apps/edr-freight-api/src/seed/gov-companies.seeder.ts
new file mode 100644
index 000000000..4fdd250e5
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/gov-companies.seeder.ts
@@ -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 {
+ 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.");
+ }
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
index e685a3cf4..690da9998 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
@@ -180,8 +180,10 @@ export default function NewBookingPage() {
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
- const [governmentInstitution, setGovernmentInstitution] = useState("");
const [companyId, setCompanyId] = useState(null);
+ // Government bookings bill to a real government company + an explicit profile.
+ const [govCompanyId, setGovCompanyId] = useState(null);
+ const [govProfileId, setGovProfileId] = useState(null);
const [freightType, setFreightType] = useState("CONTAINER");
const [originYardId, setOriginYardId] = useState(null);
const [destinationYardId, setDestinationYardId] = useState(null);
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
label: c.name || c.email || c.tin || c.id,
}));
+ // Active government companies (kind=government) the booking can bill to.
+ const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
+ queryKey: ["companies", "government", "active"],
+ queryFn: () =>
+ customersService.list({
+ page: 1,
+ pageSize: 1000,
+ kind: "government",
+ status: "active",
+ }),
+ enabled: isGovernment,
+ });
+
+ const govCompanies = govCompaniesPage?.items ?? [];
+ const govCompanyOptions = govCompanies.map((c) => ({
+ value: c.id,
+ label: c.name || c.tin || c.id,
+ }));
+
+ // Profiles (importer/exporter) of the chosen government company — the booking
+ // must link to one explicitly.
+ const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
+ const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
+ .filter((p) => p.status === "active")
+ .map((p) => ({
+ value: p.id,
+ label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
+ p.reference ? ` — ${p.reference}` : ""
+ }`,
+ }));
+
+ // Reset the chosen profile when the government company changes.
+ useEffect(() => {
+ setGovProfileId(null);
+ }, [govCompanyId]);
+
// Day-level pool: fetch only the days that have a departure on the route (no
// train, no capacity). The batch engine assigns the train after booking.
const { data: availableDays, isLoading: daysLoading } = useQuery(
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
Boolean(tradeDirection) &&
Boolean(serviceTypeId) &&
departureSatisfied &&
- (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
+ (isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
mutationFn: () =>
bookingsService.create({
isGovernment,
- governmentInstitution: isGovernment ? governmentInstitution : undefined,
- companyId: isGovernment ? undefined : companyId || undefined,
+ companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
+ companyProfileId: isGovernment ? govProfileId || undefined : undefined,
freightType,
contractType: "NEW",
equipmentReturn,
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
- setGovernmentInstitution(e.currentTarget.value)}
- required
- />
+
+
+
+
) : (
,
},
{
- label: "Billing",
+ label: "Invoices",
href: "/billing",
icon: ,
},
@@ -285,7 +286,8 @@ const App = () => {
/>
} />
} />
- } />
+ } />
+ } />
{/* Profile was merged into Settings — keep old links working. */}
`/api/payments/intents/${bookingId}`,
CHECKOUT: "/api/payments/checkout",
},
+
+ BILLING: {
+ MY_INVOICES: "/api/billing/my-invoices",
+ MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
+ PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
+ },
};
diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts
index 1b070d87d..a24cb4a6d 100644
--- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts
+++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts
@@ -1,5 +1,5 @@
-export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
-// export const API_BASE_URL = 'http://localhost:3001';
+// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
+export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the
diff --git a/apps/edr-freight-web/portal/src/lib/currency.ts b/apps/edr-freight-web/portal/src/lib/currency.ts
new file mode 100644
index 000000000..d41b4edda
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/lib/currency.ts
@@ -0,0 +1,23 @@
+/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
+export type Currency = string;
+
+const SYMBOLS: Record = {
+ USD: "$",
+ ETB: "Br",
+ DJF: "DJF",
+};
+
+/**
+ * Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
+ * Unknown currency codes fall back to printing the raw code.
+ */
+export function formatCurrency(
+ amount: number,
+ currency: Currency = "ETB",
+): string {
+ const symbol = SYMBOLS[currency] ?? currency;
+ return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}`;
+}
diff --git a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts b/apps/edr-freight-web/portal/src/lib/currentCustomer.ts
deleted file mode 100644
index 47fc1efa6..000000000
--- a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
-import { customers, type Customer } from "@/pages/customers/customers.mock";
-
-/**
- * Mock "logged-in customer". When auth integrates, replace this with the value
- * pulled from `@edr/iamui-common` / the JWT context.
- */
-const CURRENT_CUSTOMER_ID = 1;
-
-export function getCurrentCustomer(): Customer {
- return (
- customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
- (customers[0] as Customer)
- );
-}
-
-export function getMyInvoices(): Invoice[] {
- const me = getCurrentCustomer();
- return invoices.filter((inv) => inv.customerId === me.id);
-}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx
index cb350916d..c9e073eb5 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx
@@ -1,5 +1,5 @@
-import type { Currency } from "@/pages/billing/invoices.mock";
-import { formatCurrency } from "@/pages/billing/invoices.mock";
+import type { Currency } from "@/lib/currency";
+import { formatCurrency } from "@/lib/currency";
import { Group, Grid, Select, Stack } from "@mantine/core";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -34,7 +34,6 @@ export default function MyPortalPage() {
totalOutstanding,
companyName,
greeting,
- recentInvoices,
dashboard,
volumePoints,
maxVolume,
@@ -120,7 +119,7 @@ export default function MyPortalPage() {
-
+
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx
index 27b74cb6f..c84f94c35 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx
@@ -1,7 +1,7 @@
import { Box, Group, Skeleton, Text } from "@mantine/core";
import { memo } from "react";
-import type { Currency } from "@/pages/billing/invoices.mock";
-import { formatCurrency } from "@/pages/billing/invoices.mock";
+import type { Currency } from "@/lib/currency";
+import { formatCurrency } from "@/lib/currency";
import { formatPct } from "../constants";
import { Card } from "./Card";
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
index 3ebb9606f..8d350b93f 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx
@@ -1,8 +1,8 @@
-import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
-import { formatCurrency } from "@/pages/billing/invoices.mock";
+import { formatCurrency } from "@/lib/currency";
+import type { PortalInvoice } from "@/services/invoices.service";
import { Box, Group, Stack, Text } from "@mantine/core";
-import { format } from "date-fns";
-import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
+import { Freight } from "@edr/types";
+import { ChevronRight, Clock3 } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { cv, INVOICE_BADGE } from "../constants";
@@ -10,26 +10,26 @@ import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
interface InvoicesSectionProps {
- invoices: Array<{
- id: number;
- number: string;
- bookingReference: string;
- amount: number;
- currency: Currency;
- status: InvoiceStatus;
- dueDate: string;
- paidDate: string | null;
- }>;
+ invoices: PortalInvoice[];
}
+/** Max rows to list in the compact dashboard card. */
+const MAX_ROWS = 5;
+
+const titleCase = (v: string) =>
+ v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
+
export const InvoicesSection = memo(function InvoicesSection({
invoices,
}: InvoicesSectionProps) {
- const outstandingInvoices = invoices.filter(
- (inv) => inv.status === "Sent" || inv.status === "Overdue",
+ // Dashboard shows unpaid invoices only (pending + overdue).
+ const pendingInvoices = invoices.filter(
+ (inv) =>
+ inv.status === Freight.InvoiceStatus.Pending ||
+ inv.status === Freight.InvoiceStatus.Overdue,
);
- const totalOutstanding = outstandingInvoices.reduce(
- (sum, inv) => sum + inv.amount,
+ const totalOutstanding = pendingInvoices.reduce(
+ (sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({
{formatCurrency(totalOutstanding || 0, "ETB")}
-
-
- {outstandingInvoices.length || 2} invoices unpaid
-
-
-
-
- Pay all
-
-
-
+
+ {pendingInvoices.length} invoices unpaid
+
- {invoices.length === 0 ? (
-
+ {pendingInvoices.length === 0 ? (
+
) : (
- {invoices.map((invoice, i) => {
+ {pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => {
const badge = INVOICE_BADGE[invoice.status];
- const dueText =
- invoice.status === "Paid"
- ? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
- : invoice.status === "Overdue"
- ? "Overdue 3 days"
- : `Due ${invoice.dueDate}`;
- const DueIcon =
- invoice.status === "Paid" ? CheckCircle2 : Clock3;
- const dueIconColor =
- invoice.status === "Paid"
- ? cv("edr-green.5")
- : cv("edr-muted");
+ const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
+ const dueText = isOverdue
+ ? "Overdue"
+ : `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
+ const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted");
return (
{i > 0 && }
-
-
+
-
-
- {invoice.number}
-
-
- {invoice.bookingReference}
-
-
-
- {formatCurrency(invoice.amount, invoice.currency)}
-
-
-
-
-
-
- {dueText}
+
+
+
+ {invoice.invoiceNumber}
+
+
+ {titleCase(invoice.source)} · {titleCase(invoice.type)}
+
+
+
+ {formatCurrency(
+ Number(invoice.totalAmount),
+ invoice.currency,
+ )}
-
-
- {badge.label}
-
-
-
-
+
+
+
+
+ {dueText}
+
+
+ {badge && (
+
+
+ {badge.label}
+
+
+ )}
+
+
+
);
})}
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx
index dae90842b..3ddd9be4d 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx
@@ -1,4 +1,4 @@
-import { formatCurrency } from "@/pages/billing/invoices.mock";
+import { formatCurrency } from "@/lib/currency";
import { SimpleGrid } from "@mantine/core";
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
import { memo } from "react";
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
index 70e58f77c..ea5567ab8 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
@@ -12,7 +12,7 @@ import {
Wallet,
type LucideIcon,
} from "lucide-react";
-import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
+import { Freight } from "@edr/types";
export const cv = (token: string) => {
const [name, shade] = token.split(".");
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
};
export const INVOICE_BADGE: Record<
- InvoiceStatus,
+ Freight.InvoiceStatus,
{ label: string; bg: string; text: string }
> = {
- Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
- Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
- Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
- Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
- Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
+ [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
+ [Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
+ [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
+ [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
+ [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
+ [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
};
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
index 8c1bb6b04..08a3ec97e 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts
@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
-import { useMemo } from "react";
+import { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth";
-import { getMyInvoices } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData(selectedProfileId?: string) {
const { user, customer, company } = useAuth();
- const myInvoices = useMemo(() => getMyInvoices(), []);
+
+ const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
+ const myInvoices = invoicesQuery.data ?? [];
const companyProfiles = company?.company?.companyProfiles ?? [];
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
).length;
const outstandingInvoices = myInvoices.filter(
- (inv) => inv.status === "Sent" || inv.status === "Overdue",
+ (inv) =>
+ inv.status === Freight.InvoiceStatus.Pending ||
+ inv.status === Freight.InvoiceStatus.Overdue,
);
const totalOutstanding = outstandingInvoices.reduce(
- (sum, inv) => sum + inv.amount,
+ (sum, inv) => sum + Number(inv.totalAmount),
0,
);
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
bookingsQuery,
dashboardQuery,
contractsQuery,
+ invoicesQuery,
allContracts,
recentContracts,
activeContractsCount,
diff --git a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx
deleted file mode 100644
index 8b7ac4557..000000000
--- a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx
+++ /dev/null
@@ -1,382 +0,0 @@
-import { useMemo, useState } from "react";
-import {
- AlertCircle,
- Clock,
- DollarSign,
- Download,
- Filter,
- MoreHorizontal,
- Pencil,
- Plus,
- Receipt,
- Search,
- Trash2,
-} from "lucide-react";
-
-import Breadcrumbs from "@/components/Breadcrumbs";
-import NewInvoicePage from "./NewInvoicePage";
-import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
-import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
-import {
- DataTable,
- DataTableFooter,
- type ColumnDef,
- usePagination,
- Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
- Input,
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
-} from "@edr/ui-common";
-
-type FilterValue = "All" | InvoiceStatus;
-
-const FILTERS: FilterValue[] = [
- "All",
- "Draft",
- "Sent",
- "Paid",
- "Overdue",
- "Cancelled",
-];
-
-export default function BillingPage() {
- const { pagination, setPagination } = usePagination({ pageSize: 10 });
- const [filter, setFilter] = useState("All");
- const [query, setQuery] = useState("");
-
- const filtered = useMemo(() => {
- const q = query.trim().toLowerCase();
- return invoices.filter((inv) => {
- if (filter !== "All" && inv.status !== filter) return false;
- if (!q) return true;
- return (
- inv.number.toLowerCase().includes(q) ||
- inv.customer.toLowerCase().includes(q) ||
- inv.bookingReference.toLowerCase().includes(q)
- );
- });
- }, [filter, query]);
-
- const total = filtered.length;
- const pageCount = Math.ceil(total / pagination.pageSize);
- const start = pagination.pageIndex * pagination.pageSize;
- const end = Math.min(start + pagination.pageSize, total);
-
- const paginatedData = useMemo(
- () => filtered.slice(start, end),
- [start, end, filtered],
- );
-
- const totalRevenue = invoices
- .filter((inv) => inv.status === "Paid" && inv.currency === "USD")
- .reduce((sum, inv) => sum + inv.amount, 0);
- const outstanding = invoices
- .filter(
- (inv) =>
- (inv.status === "Sent" || inv.status === "Overdue") &&
- inv.currency === "USD",
- )
- .reduce((sum, inv) => sum + inv.amount, 0);
- const overdueCount = invoices.filter(
- (inv) => inv.status === "Overdue",
- ).length;
-
- const columns: ColumnDef<(typeof invoices)[number]>[] = [
- {
- id: "invoice",
- header: "Invoice",
- cell: ({ row }) => {
- const inv = row.original;
- return (
-
-
-
-
-
-
{inv.number}
-
Issued {inv.issueDate}
-
-
- );
- },
- },
- {
- accessorKey: "customer",
- header: "Customer",
- },
- {
- accessorKey: "bookingReference",
- header: "Booking",
- },
- {
- id: "amount",
- header: "Amount",
- cell: ({ row }) => {
- const inv = row.original;
- return (
-
- {formatCurrency(inv.amount, inv.currency)}
-
- );
- },
- },
- {
- accessorKey: "dueDate",
- header: "Due Date",
- },
- {
- accessorKey: "status",
- header: "Status",
- cell: ({ row }) => ,
- },
- {
- id: "actions",
- size: 40,
- cell: ({ row }) => {
- const invoice = row.original;
- return (
- e.stopPropagation()}
- >
-
-
-
-
-
-
-
- Download
-
-
- e.preventDefault()}>
-
- Edit
-
-
-
-
- e.preventDefault()}
- variant="destructive"
- >
-
- Void
-
-
-
-
-
- );
- },
- },
- ];
-
- return (
-
-
-
-
-
-
-
- Billing
-
-
- Manage invoices, payments, and financial records.
-
-
-
-
-
-
- {
- setQuery(e.target.value);
- setPagination({
- pageIndex: 0,
- pageSize: pagination.pageSize,
- });
- }}
- placeholder="Search invoices..."
- className="pl-8!"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
Total Revenue (USD)
-
- {formatCurrency(totalRevenue, "USD")}
-
-
-
-
-
-
-
-
-
-
-
-
Outstanding (USD)
-
- {formatCurrency(outstanding, "USD")}
-
-
-
-
-
-
-
-
-
-
-
-
Overdue Invoices
-
- {overdueCount}
-
-
-
-
-
-
-
-
-
- {FILTERS.map((f) => {
- const isActive = f === filter;
- const count =
- f === "All"
- ? invoices.length
- : invoices.filter((inv) => inv.status === f).length;
- return (
-
- );
- })}
-
-
-
-
-
-
- Invoices
-
- Issued invoices and their payment status.
-
-
-
-
-
-
-
- { }}
- pagination={{
- pageIndex: pagination.pageIndex,
- pageSize: pagination.pageSize,
- pageCount: pageCount,
- totalCount: total,
- }}
- tableOptions={{
- state: { pagination },
- onPaginationChange: setPagination,
- }}
- containerClassName="border-b shadow-none"
- footer={DataTableFooter}
- />
-
-
-
-
- );
-}
-
-function StatusBadge({ status }: { status: InvoiceStatus }) {
- const styles: Record = {
- Draft: "bg-slate-100 text-slate-600",
- Sent: "bg-sky-100 text-sky-700",
- Paid: "bg-emerald-100 text-emerald-700",
- Overdue: "bg-red-100 text-red-700",
- Cancelled: "bg-amber-100 text-amber-700",
- };
-
- return (
-
- {status}
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx b/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx
deleted file mode 100644
index a4e278cb1..000000000
--- a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { ReactNode } from "react";
-
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-
-import { Button } from "@/components/ui/button";
-
-export interface DeleteInvoiceDialogProps {
- invoiceNumber: string;
- onConfirm?: () => void;
- children: ReactNode;
-}
-
-export default function DeleteInvoiceDialog({
- invoiceNumber,
- onConfirm,
- children,
-}: DeleteInvoiceDialogProps) {
- return (
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx
new file mode 100644
index 000000000..510c7aad7
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx
@@ -0,0 +1,247 @@
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ Alert,
+ Box,
+ Button,
+ Center,
+ Divider,
+ Group,
+ Loader,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Table,
+ Text,
+ Title,
+} from "@mantine/core";
+import { ArrowLeft, CreditCard, Info } from "lucide-react";
+
+import { api } from "@/services/api";
+import { formatCurrency } from "@/lib/currency";
+import { BORDER, INK, MUTED } from "../contracts/contract-ui";
+import {
+ billedTo,
+ fmtDate,
+ InvoiceStatusBadge,
+ isPayable,
+ titleCase,
+} from "./invoice-ui";
+
+function MetaItem({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export default function InvoiceDetailPage() {
+ const { id = "" } = useParams();
+ const navigate = useNavigate();
+
+ const { data: invoice, isLoading, isError } = useQuery(
+ api.invoices.get.queryOptions({ input: { id } }),
+ );
+
+ const payMutation = useMutation(
+ api.invoices.pay.mutationOptions({
+ onSuccess: (res) => {
+ const url = res.clientAction?.url;
+ if (url) window.location.href = url;
+ },
+ }),
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !invoice) {
+ return (
+
+ }
+ onClick={() => navigate("/billing")}
+ mb="md"
+ >
+ Back to invoices
+
+
+ We couldn't load this invoice. It may not exist or you may not have
+ access to it.
+
+
+ );
+ }
+
+ const payable = isPayable(invoice.status);
+ const lines = invoice.lines ?? [];
+
+ const handlePay = () => {
+ const returnUrl = `${window.location.origin}/payment/success`;
+ const failureUrl = `${window.location.origin}/payment/failure`;
+ payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
+ };
+
+ return (
+
+
+ }
+ onClick={() => navigate("/billing")}
+ style={{ alignSelf: "flex-start" }}
+ styles={{ root: { fontWeight: 600 } }}
+ >
+ Back to invoices
+
+
+ {/* Header */}
+
+
+
+ {invoice.invoiceNumber}
+
+
+
+ {payable && (
+ }
+ loading={payMutation.isPending}
+ onClick={handlePay}
+ styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
+ >
+ Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
+
+ )}
+
+
+ {payMutation.isError && (
+ } title="Payment could not be started">
+ Please try again, or contact support if the problem persists.
+
+ )}
+
+ {/* Summary */}
+
+
+
+
+
+
+
+
+
+
+
+
+ Total
+
+
+ {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
+
+
+
+
+ {/* Line items */}
+
+
+
+ Line items
+
+
+
+
+
+
+ Charge
+ Qty
+ Unit Rate
+ Amount
+
+
+
+ {lines.length === 0 && (
+
+
+
+
+ No line items on this invoice.
+
+
+
+
+ )}
+ {lines.map((line) => (
+
+
+
+ {titleCase(line.chargeType)}
+
+ {line.description && (
+
+ {line.description}
+
+ )}
+
+
+
+ {Number(line.quantity)}
+
+
+
+
+ {formatCurrency(Number(line.unitRate), line.currency)}
+
+
+
+
+ {formatCurrency(Number(line.amount), line.currency)}
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
new file mode 100644
index 000000000..e1d45857f
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
@@ -0,0 +1,537 @@
+import { useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Box,
+ Button,
+ Center,
+ Group,
+ Loader,
+ Paper,
+ Select,
+ Stack,
+ Table,
+ Text,
+ TextInput,
+ Title,
+} from "@mantine/core";
+import {
+ AlertTriangle,
+ ChevronLeft,
+ ChevronRight,
+ CreditCard,
+ Eye,
+ FileStack,
+ Inbox,
+ Receipt,
+ Search,
+ Wallet,
+ X,
+} from "lucide-react";
+import { Freight } from "@edr/types";
+
+import { api } from "@/services/api";
+import { formatCurrency } from "@/lib/currency";
+import {
+ BORDER,
+ GREEN,
+ INK,
+ MUTED,
+ StatCard,
+} from "../contracts/contract-ui";
+import {
+ billedTo,
+ fmtDate,
+ InvoiceStatusBadge,
+ isPayable,
+ PAYABLE_STATUSES,
+ titleCase,
+} from "./invoice-ui";
+
+const PAGE_SIZES = ["10", "25", "50"];
+
+export default function InvoicesList() {
+ const navigate = useNavigate();
+ const [query, setQuery] = useState("");
+ const [statusFilter, setStatusFilter] = useState(null);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(10);
+
+ const { data, isLoading, isError } = useQuery(
+ api.invoices.listMy.queryOptions(),
+ );
+
+ const all = useMemo(() => data ?? [], [data]);
+
+ const stats = useMemo(() => {
+ const outstanding = all.filter((i) =>
+ PAYABLE_STATUSES.includes(i.status),
+ ).length;
+ const overdue = all.filter(
+ (i) => i.status === Freight.InvoiceStatus.Overdue,
+ ).length;
+ return { outstanding, overdue, total: all.length };
+ }, [all]);
+
+ const rows = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ return all.filter((inv) => {
+ if (statusFilter && inv.status !== statusFilter) return false;
+ if (!q) return true;
+ return (
+ inv.invoiceNumber.toLowerCase().includes(q) ||
+ inv.source.toLowerCase().includes(q) ||
+ inv.sourceId.toLowerCase().includes(q) ||
+ billedTo(inv).toLowerCase().includes(q)
+ );
+ });
+ }, [all, query, statusFilter]);
+
+ const total = rows.length;
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
+ const clampedIndex = Math.min(pageIndex, pageCount - 1);
+ const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
+ const end = Math.min((clampedIndex + 1) * pageSize, total);
+ const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
+
+ const resetPage = () => setPageIndex(0);
+ const goToPage = (i: number) =>
+ setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
+
+ const hasFilters = !!query || !!statusFilter;
+
+ return (
+
+
+ {/* Header */}
+
+
+ Invoices
+
+
+
+ {/* Summary strip */}
+
+
+
+
+
+
+ {/* Search + filters */}
+
+
+ }
+ value={query}
+ onChange={(e) => {
+ setQuery(e.currentTarget.value);
+ resetPage();
+ }}
+ radius="md"
+ styles={{ input: { height: 42 } }}
+ style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
+ />
+
+
+
+ {/* Table */}
+
+
+
+
+
+ Invoice
+ Billed To
+ Source
+ Amount
+ Issued
+ Due
+ Status
+ Action
+
+
+
+ {isLoading && (
+
+
+
+
+
+
+
+ )}
+
+ {!isLoading && isError && (
+
+
+
+
+ Failed to load invoices. Please try again.
+
+
+
+
+ )}
+
+ {!isLoading && !isError && pageRows.length === 0 && (
+
+
+
+
+
+ {hasFilters
+ ? "No invoices match your filters."
+ : "No invoices yet."}
+
+
+
+
+ )}
+
+ {!isLoading &&
+ !isError &&
+ pageRows.map((inv) => {
+ const payable = isPayable(inv.status);
+ return (
+ navigate(`/billing/${inv.id}`)}
+ >
+
+
+
+
+
+
+
+ {inv.invoiceNumber}
+
+
+ {titleCase(inv.type)}
+
+
+
+
+
+
+ {billedTo(inv)}
+
+
+
+
+ {titleCase(inv.source)}
+
+
+
+
+ {formatCurrency(Number(inv.totalAmount), inv.currency)}
+
+
+
+
+ {fmtDate(inv.issuedAt)}
+
+
+
+
+ {fmtDate(inv.dueAt)}
+
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ navigate(`/billing/${inv.id}`);
+ }}
+ styles={{
+ root: {
+ fontWeight: 600,
+ fontSize: 13,
+ paddingInline: 14,
+ whiteSpace: "nowrap",
+ boxShadow: payable
+ ? "0 1px 2px rgba(14,163,113,0.25)"
+ : "none",
+ },
+ }}
+ >
+ {payable ? "Pay" : "View"}
+
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Pagination footer */}
+ {!isLoading && !isError && total > 0 && (
+
+
+
+ Rows
+
+
+
+
+ }
+ disabled={clampedIndex === 0}
+ onClick={() => goToPage(clampedIndex - 1)}
+ ariaLabel="Previous page"
+ />
+ {pageNumbers(clampedIndex, pageCount).map((p, i) =>
+ p === "…" ? (
+
+ …
+
+ ) : (
+ goToPage(p)}
+ />
+ ),
+ )}
+ }
+ disabled={clampedIndex >= pageCount - 1}
+ onClick={() => goToPage(clampedIndex + 1)}
+ ariaLabel="Next page"
+ />
+
+
+ )}
+
+
+
+ );
+}
+
+/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
+function pageNumbers(active: number, count: number): (number | "…")[] {
+ if (count <= 7) return Array.from({ length: count }, (_, i) => i);
+ const out: (number | "…")[] = [0];
+ const lo = Math.max(1, active - 1);
+ const hi = Math.min(count - 2, active + 1);
+ if (lo > 1) out.push("…");
+ for (let i = lo; i <= hi; i++) out.push(i);
+ if (hi < count - 2) out.push("…");
+ out.push(count - 1);
+ return out;
+}
+
+function PageChip({
+ page,
+ active,
+ onClick,
+}: {
+ page: number;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {page + 1}
+
+ );
+}
+
+function PagerButton({
+ icon,
+ disabled,
+ onClick,
+ ariaLabel,
+}: {
+ icon: React.ReactNode;
+ disabled: boolean;
+ onClick: () => void;
+ ariaLabel: string;
+}) {
+ return (
+
+ {icon}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx b/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx
deleted file mode 100644
index 3e78132cd..000000000
--- a/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import type { ReactNode } from "react";
-import { Calendar, DollarSign, Hash } from "lucide-react";
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-
-import { customers } from "../customers/customers.mock";
-import { bookings } from "../bookings/bookings.mock";
-import type { Currency, InvoiceStatus } from "./invoices.mock";
-
-export interface InvoiceFormData {
- number?: string;
- customerId?: number;
- bookingReference?: string;
- amount?: number;
- currency?: Currency;
- status?: InvoiceStatus;
- issueDate?: string;
- dueDate?: string;
- notes?: string;
-}
-
-export interface NewInvoicePageProps {
- mode?: "create" | "edit";
- invoice?: InvoiceFormData;
- children?: ReactNode;
-}
-
-const selectClass =
- "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
-
-export default function NewInvoicePage({
- mode = "create",
- invoice,
- children,
-}: NewInvoicePageProps = {}) {
- const isEdit = mode === "edit";
- const title = isEdit ? "Edit Invoice" : "New Invoice";
- const description = isEdit
- ? "Update invoice details."
- : "Create a new invoice for a customer booking.";
- const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
-
- return (
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
new file mode 100644
index 000000000..1e19b1c9a
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
@@ -0,0 +1,60 @@
+import { Box } from "@mantine/core";
+import { Freight } from "@edr/types";
+
+import type { PortalInvoice } from "@/services/invoices.service";
+
+/** Statuses a customer can still pay. */
+export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
+ Freight.InvoiceStatus.Pending,
+ Freight.InvoiceStatus.Overdue,
+];
+
+export const isPayable = (status: Freight.InvoiceStatus) =>
+ PAYABLE_STATUSES.includes(status);
+
+const STATUS_STYLE: Record<
+ Freight.InvoiceStatus,
+ { label: string; bg: string; fg: string }
+> = {
+ [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
+ [Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
+ [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
+ [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
+ [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
+ [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
+};
+
+export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
+ const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
+ return (
+
+ {s.label}
+
+ );
+}
+
+export const titleCase = (v: string) =>
+ v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
+
+/** Best label for who an invoice is billed to (profile ref → profile type → company). */
+export function billedTo(inv: PortalInvoice): string {
+ const profile = inv.companyProfile;
+ if (profile?.reference) return profile.reference;
+ if (profile?.type) return titleCase(profile.type);
+ return inv.company?.name ?? "—";
+}
+
+export const fmtDate = (v: string | null | undefined) =>
+ v ? new Date(v).toLocaleDateString() : "—";
diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoices.mock.ts b/apps/edr-freight-web/portal/src/pages/billing/invoices.mock.ts
deleted file mode 100644
index 91af9cdac..000000000
--- a/apps/edr-freight-web/portal/src/pages/billing/invoices.mock.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { customers } from "../customers/customers.mock";
-import { bookings } from "../bookings/bookings.mock";
-
-export type InvoiceStatus =
- | "Draft"
- | "Sent"
- | "Paid"
- | "Overdue"
- | "Cancelled";
-
-export type Currency = "USD" | "ETB" | "DJF";
-
-export interface Invoice {
- id: number;
- number: string;
- customerId: number;
- customer: string;
- bookingReference: string;
- amount: number;
- currency: Currency;
- status: InvoiceStatus;
- issueDate: string;
- dueDate: string;
- paidDate: string | null;
- notes: string;
-}
-
-const statuses: InvoiceStatus[] = [
- "Draft",
- "Sent",
- "Paid",
- "Overdue",
- "Cancelled",
-];
-const currencies: Currency[] = ["USD", "ETB", "DJF"];
-
-export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
- const customer = customers[i % customers.length] as (typeof customers)[number];
- const booking = bookings[i % bookings.length] as (typeof bookings)[number];
- const id = i + 1;
- const issue = new Date(2026, 3, 1 + (i % 28));
- const due = new Date(issue);
- due.setDate(due.getDate() + 30);
- const status = statuses[i % statuses.length] as InvoiceStatus;
- const currency = currencies[i % currencies.length] as Currency;
- const baseAmount = 5000 + (i * 1234) % 25000;
- return {
- id,
- number: `INV-2026-${String(id).padStart(4, "0")}`,
- customerId: customer.id,
- customer: customer.company,
- bookingReference: booking.reference,
- amount: Math.round(baseAmount * 100) / 100,
- currency,
- status,
- issueDate: issue.toISOString().slice(0, 10),
- dueDate: due.toISOString().slice(0, 10),
- paidDate:
- status === "Paid"
- ? new Date(due.getTime() - 86400000 * (i % 7))
- .toISOString()
- .slice(0, 10)
- : null,
- notes:
- i % 3 === 0
- ? "Net 30 payment terms."
- : i % 3 === 1
- ? "Bank transfer preferred."
- : "Payment due upon receipt.",
- };
-});
-
-export function getInvoiceById(id: number | string): Invoice | undefined {
- const numericId = typeof id === "string" ? Number(id) : id;
- return invoices.find((inv) => inv.id === numericId);
-}
-
-export function formatCurrency(amount: number, currency: Currency): string {
- const symbols: Record = {
- USD: "$",
- ETB: "Br",
- DJF: "DJF",
- };
- return `${symbols[currency]} ${amount.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}`;
-}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 4bb5e16e8..1526e69b6 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -30,6 +30,12 @@ import {
IntentStatus,
} from "./payments.service";
import { consignmentsService } from "./consignments.service";
+import {
+ invoicesService,
+ PortalInvoice,
+ PortalInvoiceDetail,
+ PayInvoicePayload,
+} from "./invoices.service";
import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
@@ -597,4 +603,23 @@ export const api = {
({ optionId }) => dropdownSettingsService.removeOption(optionId),
),
},
+
+ invoices: {
+ listMy: endpoint(
+ "invoices",
+ "listMy",
+ invoicesService.listMy,
+ ),
+
+ get: endpoint<{ id: string }, PortalInvoiceDetail>(
+ "invoices",
+ "get",
+ ({ id }) => invoicesService.get(id),
+ ),
+
+ pay: endpoint<
+ { id: string; payload?: PayInvoicePayload },
+ InitiateResponse
+ >("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
+ },
};
diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts
new file mode 100644
index 000000000..99b1d0dee
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts
@@ -0,0 +1,49 @@
+import type { Freight } from "@edr/types";
+
+import { URL_CONSTANTS } from "@/constants/URLS";
+import { client } from "../utils/api";
+import type { InitiateResponse } from "./payments.service";
+
+const B = URL_CONSTANTS.BILLING;
+
+/** A customer-facing invoice row, as returned by `GET /billing/my-invoices`. */
+export type PortalInvoice = Freight.IInvoice;
+
+/** An invoice plus its line items, as returned by `GET /billing/my-invoices/:id`. */
+export type PortalInvoiceDetail = Freight.IInvoice & {
+ lines: Freight.IInvoiceLine[];
+};
+
+export interface PayInvoicePayload {
+ method?: string;
+ platform?: "web" | "mobile";
+ payerAccount?: string;
+ returnUrl?: string;
+ failureUrl?: string;
+}
+
+export const invoicesService = {
+ /** Every invoice billed to the signed-in customer's company, newest first. */
+ listMy: async (): Promise => {
+ const { data } = await client.get(B.MY_INVOICES);
+ return data.data ?? data;
+ },
+
+ /** One of the customer's invoices, with its line items. */
+ get: async (id: string): Promise => {
+ const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
+ return data.data ?? data;
+ },
+
+ /** Initiate gateway payment for an open invoice; returns the client action. */
+ pay: async (
+ id: string,
+ payload: PayInvoicePayload = {},
+ ): Promise => {
+ const { data } = await client.post(B.PAY_INVOICE(id), {
+ platform: "web",
+ ...payload,
+ });
+ return data.data ?? data;
+ },
+};
diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql
index 351c44637..4b1fbd19c 100644
--- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql
+++ b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql
@@ -1,7 +1,7 @@
-- Migration: Add Configurable Fare Management System
-- Main fare configuration table
-CREATE TABLE "fare_configurations" (
+CREATE TABLE IF NOT EXISTS "fare_configurations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
);
-- Rate structure by nationality and coach/position
-CREATE TABLE "fare_rate_rules" (
+CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
@@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" (
);
-- Configurable fare components (insurance, premiums, service charges, taxes)
-CREATE TABLE "fare_components" (
+CREATE TABLE IF NOT EXISTS "fare_components" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
);
-- Age-based pricing rules
-CREATE TABLE "age_pricing_rules" (
+CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"rule_name" TEXT NOT NULL,
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
);
-- Audit trail for configuration changes
-CREATE TABLE "fare_configuration_audit" (
+CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
"id" TEXT NOT NULL,
"fare_config_id" TEXT NOT NULL,
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
@@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" (
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
);
--- Foreign key constraints
-ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+-- Foreign key constraints (idempotent)
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
+ ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
+ ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
+ ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
+ ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ END IF;
+END $$;
--- Indexes for performance
-CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
-CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
-CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
+-- Indexes for performance (idempotent)
+CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
+CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
+CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
-CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
-CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
-CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
+CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
+CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
+CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
--- Add legacy mode flag to existing fare tables for gradual migration
-ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
-ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
+-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
+ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
+ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
-- Add feature flag support
-CREATE TABLE "system_features" (
+CREATE TABLE IF NOT EXISTS "system_features" (
"id" TEXT NOT NULL,
"feature_name" TEXT NOT NULL UNIQUE,
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
@@ -114,4 +124,4 @@ CREATE TABLE "system_features" (
-- Insert the configurable fares feature flag
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
-VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);
\ No newline at end of file
+VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 7e8947034..2ca7d5ac4 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -483,8 +483,8 @@ export class BookingsService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
- const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
- const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -660,8 +660,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
- const taxesMinor = Math.round(combinedBase * 0.05);
- const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -854,8 +854,8 @@ export class BookingsService {
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
- const taxesMinor = Math.round(combinedBase * 0.05);
- const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -1060,7 +1060,7 @@ export class BookingsService {
loyaltyRedemptionPoints?: number
) {
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
- const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
+ const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -1076,8 +1076,8 @@ export class BookingsService {
}
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
- const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
- const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
return {
baseFareMinor,
@@ -1103,6 +1103,8 @@ export class BookingsService {
nationality?: string,
originStopSeq?: number,
destStopSeq?: number,
+ originStationId?: string,
+ destinationStationId?: string,
): Promise {
const now = new Date();
@@ -1149,13 +1151,13 @@ export class BookingsService {
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
if (bestMatch) return bestMatch.baseFareMinor;
- // 3. FareEngine — distance × rate-per-km from the schedule's route
+ // 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
if (schedule?.routeId) {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
- originStationId: schedule.originStationId,
- destinationStationId: schedule.destinationStationId,
+ originStationId: originStationId ?? schedule.originStationId,
+ destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index cfddd77f8..6907d14a3 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
+/** Booking cutoff: reject new bookings within this many ms of departure. */
+const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
+
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
@@ -74,6 +77,10 @@ export class GuestBookingService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
+ if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
+ throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
+ }
+
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
@@ -142,7 +149,9 @@ export class GuestBookingService {
dto.seatClassId,
segmentRoute,
fullRoute,
- primaryNationality
+ primaryNationality,
+ dto.originStationId,
+ dto.destinationStationId,
);
const adultFareMinor = baseFareMinor * adultCount;
@@ -160,8 +169,8 @@ export class GuestBookingService {
}
}
- const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
- const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -293,6 +302,10 @@ export class GuestBookingService {
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
+ if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
+ throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
+ }
+
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
@@ -350,8 +363,8 @@ export class GuestBookingService {
const primaryNationality = passengersData[0]?.nationality;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
- this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
- this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
+ this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
+ this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
@@ -369,8 +382,8 @@ export class GuestBookingService {
}
}
- const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
- const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -505,6 +518,10 @@ export class GuestBookingService {
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
+ if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
+ throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
+ }
+
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -551,11 +568,11 @@ export class GuestBookingService {
this.getBaseFare(dto.scheduleId, dto.seatClassId,
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
- primaryNationality),
+ primaryNationality, dto.originStationId, dto.transitStationId),
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
- primaryNationality),
+ primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
@@ -569,8 +586,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
- const taxesMinor = Math.round(combinedBase * 0.05);
- const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, combinedBase - discountMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
@@ -702,6 +719,10 @@ export class GuestBookingService {
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
+ if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
+ throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
+ }
+
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
@@ -750,10 +771,10 @@ export class GuestBookingService {
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
- this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
- this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
- this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
- this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
+ this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
+ this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
+ this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
+ this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
@@ -951,17 +972,28 @@ export class GuestBookingService {
segmentRoute?: string,
fullRoute?: string,
nationality?: string,
+ originStationId?: string,
+ destinationStationId?: string,
): Promise {
const now = new Date();
- // 1. FareRule table — explicit override rules
- const candidates = await this.prisma.fareRule.findMany({
- where: {
- seatClassId,
- validFrom: { lte: now },
- OR: [{ validUntil: null }, { validUntil: { gte: now } }],
- },
- });
+ // 1. FareRule table — explicit override rules (same priority logic as the fare engine)
+ const [candidates, seatClass] = await Promise.all([
+ this.prisma.fareRule.findMany({
+ where: {
+ seatClassId,
+ validFrom: { lte: now },
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
+ },
+ }),
+ this.prisma.seatClass.findUnique({
+ where: { id: seatClassId },
+ select: { premiumMinor: true, insuranceFeeMinor: true },
+ }),
+ ]);
+
+ const premiumMinor = seatClass?.premiumMinor ?? 0;
+ const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
@@ -982,10 +1014,11 @@ export class GuestBookingService {
const match = candidates.find(
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
);
- if (match) return match.baseFareMinor;
+ // Return base fare + seat-class surcharges so the booking total matches the quoted fare
+ if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
}
- // 2. FareEngine — distance × rate-per-km from the schedule's route
+ // 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
@@ -995,12 +1028,15 @@ export class GuestBookingService {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
- originStationId: schedule.originStationId,
- destinationStationId: schedule.destinationStationId,
+ // Use the booking's boarding/alighting stations so the distance reflects the
+ // passenger's actual segment, not the full schedule route.
+ originStationId: originStationId ?? schedule.originStationId,
+ destinationStationId: destinationStationId ?? schedule.destinationStationId,
seatClassId,
nationality,
});
- return fare.baseFarePerPassengerMinor;
+ // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
+ return fare.farePerPassengerMinor;
} catch {
// FareEngine throws if distanceKm is missing; fall through to error
}
diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
index f2ce49791..847164551 100644
--- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
+++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
@@ -284,13 +284,16 @@ export class FareEngineService {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => {
const seatClassId = rule.seatClassId;
+ const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
+ const totalMinor = rule.baseFareMinor + taxMinor;
return {
seatClassId,
seatClassName: 'Unknown',
baseFareMinor: rule.baseFareMinor,
- totalMinor: rule.baseFareMinor,
+ taxMinor,
+ totalMinor,
billingCurrency,
- totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
+ totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
exchangeRate,
source: 'FARE_RULE',
};
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index 97cf2600e..280ef2752 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -31,6 +31,7 @@ import {
SupportedPaymentMethodDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
+ BookingAmountResponseDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -139,16 +140,33 @@ export class PaymentsController {
@ApiOperation({
summary: "List payment systems supported by the platform",
description:
- "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
+ "Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.",
})
- @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
getMethods(
- @Query("currency") currency?: string,
@Query("region") region?: PaymentRegionEnum,
) {
- return this.service.getSupportedPaymentMethods(region, currency);
+ return this.service.getSupportedPaymentMethods(region);
+ }
+
+ @Get("booking-amount")
+ @SetMetadata('isPublic', true)
+ @ApiOperation({
+ summary: "Get booking amount in a specific currency",
+ description:
+ "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
+ "If currency is ETB the stored amount is returned as-is (no conversion). " +
+ "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
+ })
+ @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" })
+ @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" })
+ @ApiOkResponse({ type: BookingAmountResponseDto })
+ getBookingAmount(
+ @Query("bookingId") bookingId: string,
+ @Query("currency") currency: string,
+ ) {
+ return this.service.getBookingAmountByCurrency(bookingId, currency);
}
@Get("checkout")
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts
index c1b168138..410716b85 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts
@@ -136,3 +136,9 @@ export class IntentStatusDto {
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
}
+
+export class BookingAmountResponseDto {
+ @ApiProperty({ example: 'booking-uuid' }) booking_id: string;
+ @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
+ @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
+}
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index 80980cadb..af496a5a2 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -476,7 +476,7 @@ export class PaymentsService {
});
}
- getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
+ getSupportedPaymentMethods(region?: PaymentRegionEnum) {
return this.prisma.paymentMethod.findMany({
where: {
enabled: true,
@@ -490,12 +490,41 @@ export class PaymentsService {
},
}
: {}),
- ...(currency ? { currency: currency.toUpperCase() } : {}),
},
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
});
}
+ async getBookingAmountByCurrency(
+ bookingId: string,
+ currency: string,
+ ): Promise<{ booking_id: string; currency: string; amount: number }> {
+ const booking = await this.prisma.booking.findUnique({
+ where: { id: bookingId },
+ select: { id: true, totalMinor: true },
+ });
+ if (!booking) throw new NotFoundException('Booking not found');
+
+ const requestedCurrency = currency.toUpperCase();
+ const amountInETB = booking.totalMinor / 100;
+
+ if (requestedCurrency === 'ETB') {
+ return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
+ }
+
+ const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
+ where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
+ orderBy: { effectiveDate: 'desc' },
+ });
+ if (!exchangeRate) {
+ throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
+ }
+
+ const rate = Number(exchangeRate.rate);
+ const converted = parseFloat((amountInETB * rate).toFixed(2));
+ return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
+ }
+
/**
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 3a7b02682..e14db6b0a 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -474,8 +474,8 @@ export class SearchService {
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
- const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
- const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
+ const taxesMinor = 0;
+ const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
const displayTotalMinor = displayCurrency !== Currency.ETB
diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
index 70009d1d8..bc52cc613 100644
--- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
+++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
-/** Minutes before departure at which each action fires. */
-const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
-const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
+/** Maximum time (hours) a passenger has to pay after booking. */
+const MAX_PAYMENT_HOURS = 2;
+/** Minutes before departure: cutoff for new bookings and payment deadline. */
+const CUTOFF_MINUTES = 30;
-/** Half-width of the reminder detection window (cron runs every 2 min). */
-const REMINDER_WINDOW_MINUTES = 2;
+/**
+ * payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
+ */
+function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
+ const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
+ const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
+ return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
+}
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
@@ -28,66 +35,72 @@ export class TasksService {
) {}
// ─────────────────────────────────────────────────────────────────────────
- // Every 2 min: advance TrainSchedule statuses (departure / arrival).
+ // Every 1 min: advance TrainSchedule statuses.
+ //
+ // SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings)
+ // BOARDING → EN_ROUTE at actual departure
+ // EN_ROUTE → ARRIVED at arrival time
// ─────────────────────────────────────────────────────────────────────────
- @Cron('*/2 * * * *')
+ @Cron('*/1 * * * *')
async syncScheduleStatuses() {
const now = new Date();
+ const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
- const [departed, arrived] = await Promise.all([
+ const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
- where: { status: 'SCHEDULED', departureAt: { lte: now } },
+ where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
+ data: { status: 'BOARDING' },
+ }),
+ this.prisma.trainSchedule.updateMany({
+ where: { status: 'BOARDING', departureAt: { lte: now } },
data: { status: 'EN_ROUTE' },
}),
this.prisma.trainSchedule.updateMany({
- where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
+ where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
data: { status: 'ARRIVED' },
}),
]);
- if (departed.count > 0 || arrived.count > 0) {
+ if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
this.logger.log(
- `Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
+ `Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
- // Every 2 min: payment deadline enforcement.
+ // Every 1 min: payment deadline enforcement.
//
- // • 3 h before departure → send one SMS reminder to complete payment.
- // • 2 h before departure → cancel booking if payment is still pending
- // and notify the passenger by SMS.
+ // Reminder — sent once at the midpoint of the booking's payment window:
+ // reminder_at = booking_time + total_window / 2
//
- // Example: train departs 08:00
- // 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
- // 06:00 → booking auto-cancelled, cancellation SMS sent
+ // Cancel — when now ≥ payment_deadline
+ // payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
+ //
+ // Examples (departure 10:00, cutoff 9:30):
+ // Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45
+ // Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15
// ─────────────────────────────────────────────────────────────────────────
- @Cron('*/2 * * * *')
+ @Cron('*/1 * * * *')
async enforcePaymentDeadlines() {
const now = new Date();
-
await Promise.all([
this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now),
]);
}
- // ── 3-hour reminder ───────────────────────────────────────────────────────
+ // ── Send reminder at the midpoint of each booking's payment window ────────
private async sendPaymentReminders(now: Date) {
- // Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
- // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
- const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
- const reminderMs = REMINDER_MINUTES * 60 * 1000;
-
- const windowStart = new Date(now.getTime() + reminderMs - windowMs);
- const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
+ // Only look at bookings created within the last 3 h with a future departure.
+ const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
- schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
+ createdAt: { gte: threeHoursAgo },
+ schedule: { departureAt: { gte: now } },
} as any,
include: {
schedule: {
@@ -101,15 +114,28 @@ export class TasksService {
for (const booking of bookings) {
try {
- const dep = booking.schedule.departureAt as Date;
- const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
- const origin = booking.schedule.originStation?.name ?? '';
- const dest = booking.schedule.destinationStation?.name ?? '';
+ const createdAt = booking.createdAt as Date;
+ const dep = booking.schedule.departureAt as Date;
+ const paymentDeadline = computePaymentDeadline(createdAt, dep);
+ const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
+
+ // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
+ if (totalWindowMs < 2 * 60 * 1000) continue;
+
+ // Remind once, at the midpoint of the total payment window
+ const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2);
+ if (now < reminderAt) continue;
+
+ const origin = booking.schedule.originStation?.name ?? '';
+ const dest = booking.schedule.destinationStation?.name ?? '';
+ const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime());
+ const remainingMin = Math.round(remainingMs / 60_000);
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` +
- `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
+ `Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` +
+ `or your booking will be cancelled.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
@@ -121,7 +147,8 @@ export class TasksService {
});
this.logger.log(
- `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
+ `Payment reminder sent: ${booking.bookingRef} ` +
+ `(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`,
);
} catch (err) {
this.logger.error(
@@ -131,14 +158,22 @@ export class TasksService {
}
}
- // ── 2-hour auto-cancel ────────────────────────────────────────────────────
+ // ── Cancel bookings whose payment deadline has passed ─────────────────────
private async cancelExpiredPendingBookings(now: Date) {
- const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
+ const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
+ const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
+ // payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
+ // Deadline is reached when either branch of the MIN is in the past:
+ // (a) createdAt ≤ now - 2h → 2-hour max window elapsed
+ // (b) departureAt ≤ now + 30min → departure within 30 min
const expiredBookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
- schedule: { departureAt: { lte: cutoff } },
+ OR: [
+ { createdAt: { lte: twoHoursAgo } },
+ { schedule: { departureAt: { lte: departureCutoff } } },
+ ],
},
include: {
schedule: {
@@ -151,8 +186,16 @@ export class TasksService {
},
});
+ let cancelledCount = 0;
+
for (const booking of expiredBookings) {
try {
+ // Re-verify exact deadline to avoid racing with a concurrent payment confirmation
+ const createdAt = booking.createdAt as Date;
+ const dep = booking.schedule.departureAt as Date;
+ const paymentDeadline = computePaymentDeadline(createdAt, dep);
+ if (now < paymentDeadline) continue;
+
// 1. Release held seats (Journey rows are the occupancy source of truth)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
@@ -161,12 +204,12 @@ export class TasksService {
data: {
bookingId: booking.id,
cancelledBy: 'SYSTEM',
- reason: 'Payment not completed before departure deadline',
+ reason: 'Payment not completed before deadline',
refundAmount: 0,
refundMethod: booking.paymentIntent?.method ?? 'NONE',
refundStatus: 'NOT_APPLICABLE',
},
- }).catch(() => null); // booking may already have a cancellation record
+ }).catch(() => null);
// 3. Mark cancelled
await this.prisma.booking.update({
@@ -175,22 +218,20 @@ export class TasksService {
});
// 4. Notify passenger
- const dep = booking.schedule.departureAt as Date;
const origin = booking.schedule.originStation?.name ?? '';
const dest = booking.schedule.destinationStation?.name ?? '';
const message =
`EDR: Your booking ${booking.bookingRef} ` +
`(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
- `because payment was not completed before the deadline.`;
+ `because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
- this.logger.log(
- `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
- );
+ this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
+ cancelledCount++;
} catch (err) {
this.logger.error(
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
@@ -198,8 +239,8 @@ export class TasksService {
}
}
- if (expiredBookings.length > 0) {
- this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
+ if (cancelledCount > 0) {
+ this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
}
}
}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index 29af2b29b..8c3479f83 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useEffect, useState, useRef } from 'react';
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
-import { QRCodeSVG } from 'qrcode.react';
import { format } from 'date-fns';
type BookingWithTicket = {
@@ -77,54 +76,71 @@ export default function ConfirmationPage() {
};
const handleDownloadVoucher = async () => {
- if (!_booking || !pnr) {
+ if (!pnr) {
alert('Booking data not available. Please try again.');
return;
}
+ if (!passengers.length) {
+ alert('No passenger data found.');
+ return;
+ }
setIsGeneratingVoucher(true);
try {
- console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers });
-
- const { generateVoucherPDF } = await import('@/lib/generate-voucher');
-
- const voucherData = {
- bookingRef: pnr,
- status: _booking.status || 'CONFIRMED',
- passengers: passengers.map(p => ({
- fullName: p.name,
- category: 'ADULT',
- seat: p.seatNumber ? {
- number: p.seatNumber,
- coach: 'N/A',
- seatClass: selectedSchedule?.selectedSeatClassName || 'Standard',
- } : undefined,
- })),
- schedule: {
- trainNumber: selectedSchedule?.trainNumber || 'N/A',
- trainName: 'EDR Express',
- origin: {
- name: selectedSchedule?.origin || 'Origin',
- code: 'ORG',
- city: selectedSchedule?.origin || 'Origin',
- },
- destination: {
- name: selectedSchedule?.destination || 'Destination',
- code: 'DST',
- city: selectedSchedule?.destination || 'Destination',
- },
- departureAt: selectedSchedule?.departureTime || new Date().toISOString(),
- arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(),
- },
- totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
- currency: 'ETB',
- bookingType: 'ONE_WAY',
- createdAt: new Date().toISOString(),
+ const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
+
+ const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
+ const totalFare = _booking?.totalMinor
+ || passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
+ const farePerPassenger = Math.round(totalFare / passengers.length);
+ const createdAt = _booking?.createdAt || new Date().toISOString();
+ const status = _booking?.status || 'CONFIRMED';
+
+ const outbound = {
+ trainNumber: activeSchedule?.trainNumber || 'N/A',
+ trainName: 'EDR Express',
+ origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
+ destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
+ departureAt: activeSchedule?.departureTime || new Date().toISOString(),
+ arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
+ seatClass: activeSchedule?.selectedSeatClassName,
};
- console.log('📄 Voucher data prepared:', voucherData);
- await generateVoucherPDF(voucherData);
- console.log('✅ Voucher generated successfully');
+ const inbound = inboundSchedule ? {
+ trainNumber: inboundSchedule.trainNumber || 'N/A',
+ trainName: 'EDR Express',
+ origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin },
+ destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination },
+ departureAt: inboundSchedule.departureTime || new Date().toISOString(),
+ arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(),
+ seatClass: inboundSchedule.selectedSeatClassName,
+ } : undefined;
+
+ for (let i = 0; i < passengers.length; i++) {
+ const p = passengers[i];
+ const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`;
+
+ await generatePassengerVoucherPDF({
+ bookingRef: pnr,
+ ticketNumber,
+ passengerName: p.name || `Passenger ${i + 1}`,
+ dateOfBirth: p.dateOfBirth,
+ nationality: p.nationality,
+ seatNumber: p.seatNumber,
+ outboundSeatNumber: (p as any).outboundSeatNumber,
+ inboundSeatNumber: (p as any).inboundSeatNumber,
+ status,
+ outboundSchedule: outbound,
+ inboundSchedule: inbound,
+ isRoundTrip,
+ fareMinor: farePerPassenger,
+ currency: 'ETB',
+ createdAt,
+ });
+
+ // brief pause between downloads so browsers don't block them
+ if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
+ }
} catch (error) {
console.error('❌ Failed to generate voucher:', error);
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -191,17 +207,9 @@ export default function ConfirmationPage() {
- {/* Trip Summary with QR Code */}
+ {/* Trip Details */}
-
- {/* QR Code Section */}
-
-
- {/* Trip Details */}
-
+
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
)}
-
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
index 92b82d2cb..b38234732 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
@@ -332,12 +332,135 @@ function DobPickerModal({
);
}
+// ─── phone validation ─────────────────────────────────────────────────────────
+
+type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
+
+const PHONE_PRESETS: Record
= {
+ ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' },
+ DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' },
+ OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' },
+};
+
+function getPhoneNat(nationality: string): PhoneNat {
+ const n = (nationality || '').toUpperCase();
+ if (n === 'ETHIOPIAN') return 'ETHIOPIAN';
+ if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN';
+ return 'OTHER';
+}
+
+function validatePhone(phone: string, nationality: string): string | null {
+ const normalized = (phone || '').replace(/[\s\-().]/g, '');
+ if (!normalized) return 'Phone number is required';
+ const nat = getPhoneNat(nationality);
+ if (nat === 'ETHIOPIAN') {
+ if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null;
+ return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)';
+ }
+ if (nat === 'DJIBOUTIAN') {
+ if (/^\+253\d{8}$/.test(normalized)) return null;
+ return 'Invalid Djiboutian phone number (e.g., +25377123456)';
+ }
+ if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null;
+ return 'Invalid international phone number (e.g., +14155552671)';
+}
+
+function stripPhonePrefix(stored: string, nat: PhoneNat): string {
+ const code = PHONE_PRESETS[nat].code;
+ if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length);
+ if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1);
+ return stored;
+}
+
+function buildFullNumber(localInput: string, nat: PhoneNat): string {
+ const stripped = localInput.replace(/[\s\-().]/g, '');
+ if (!stripped) return stripped;
+ if (nat === 'ETHIOPIAN') {
+ if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped;
+ return '+251' + stripped;
+ }
+ if (nat === 'DJIBOUTIAN') {
+ if (stripped.startsWith('+')) return stripped;
+ return '+253' + stripped;
+ }
+ return stripped.startsWith('+') ? stripped : '+' + stripped;
+}
+
+function PhoneInput({
+ nationality,
+ storedValue,
+ onInterimChange,
+ onNormalized,
+ error,
+}: {
+ nationality: string;
+ storedValue: string;
+ onInterimChange: (full: string) => void;
+ onNormalized: (full: string) => void;
+ error?: string;
+}) {
+ const nat = getPhoneNat(nationality);
+ const preset = PHONE_PRESETS[nat];
+ const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat));
+ const prevStoredRef = useRef(storedValue);
+
+ useEffect(() => {
+ if (storedValue !== prevStoredRef.current) {
+ prevStoredRef.current = storedValue;
+ setLocalInput(stripPhonePrefix(storedValue || '', nat));
+ }
+ }, [storedValue, nat]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const raw = e.target.value;
+ setLocalInput(raw);
+ onInterimChange(buildFullNumber(raw, nat));
+ };
+
+ const handleBlur = () => {
+ const full = buildFullNumber(localInput, nat);
+ setLocalInput(stripPhonePrefix(full, nat));
+ onNormalized(full);
+ };
+
+ return (
+
+
+
+ {preset.flag}
+ {preset.code}
+
+
+
+ {error ? (
+
{error}
+ ) : (
+
Format: {preset.hint}
+ )}
+
+ );
+}
+
+// ─── passenger zod schema ──────────────────────────────────────────────────────
+
const passengerSchema = z.object({
name: z.string().min(2, 'Full name is required (min 2 characters)'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
gender: z.string().min(1, 'Gender is required'),
nationality: z.string().min(1, 'Nationality is required'),
- phone: z.string().min(1, 'Phone number is required'),
+ phone: z.string(),
email: z.string().optional(),
nationalId: z.string().optional(),
passportNumber: z.string().optional(),
@@ -358,6 +481,10 @@ const passengerSchema = z.object({
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
}
}
+ const phoneError = validatePhone(data.phone, data.nationality);
+ if (phoneError) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] });
+ }
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) {
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
@@ -769,14 +896,13 @@ export default function PassengersPage() {
{/* Phone */}
-
setValue(`passengers.${index}.phone`, v)}
+ onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
+ error={errors.passengers?.[index]?.phone?.message}
/>
- {errors.passengers?.[index]?.phone && (
-
{errors.passengers[index]?.phone?.message}
- )}
{/* Email */}
@@ -850,14 +976,13 @@ export default function PassengersPage() {
{/* Phone */}
-
setValue(`passengers.${index}.phone`, v)}
+ onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
+ error={errors.passengers?.[index]?.phone?.message}
/>
- {errors.passengers?.[index]?.phone && (
-
{errors.passengers[index]?.phone?.message}
- )}
{/* Email */}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index 6527a88cd..989516839 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => {
return Smartphone;
};
-const NATIONALITY_TO_CURRENCY: Record = {
- ETHIOPIAN: 'ETB',
- DJIBOUTIAN: 'DJF',
-};
export default function PaymentPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState(null);
+ const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
- const displayCurrency: 'ETB' | 'DJF' | 'USD' =
- NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD';
+ const displayCurrency = 'ETB' as const;
// Keep payment store in sync so the mutation picks up the right currency.
useEffect(() => {
@@ -54,7 +50,22 @@ export default function PaymentPage() {
},
});
- // Calculate total amount
+ // Fetch actual booking amount from API when a payment method is selected
+ const amountCurrency = selectedMethodCurrency || displayCurrency;
+
+ const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
+ queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod],
+ queryFn: async () => {
+ const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
+ console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod });
+ const response: any = await apiClient.get(url);
+ console.log('[BookingAmount] Response:', response);
+ return response;
+ },
+ enabled: !!selectedMethod && !!bookingId,
+ });
+
+ // Fallback: estimate from local store while API hasn't responded yet
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
(sum) => sum + (outboundSchedule.baseFareAdult || 0),
0,
@@ -69,8 +80,12 @@ export default function PaymentPage() {
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
);
-
- const totalAmount = baseFare;
+
+ // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
+ const totalAmount = bookingAmountData != null
+ ? Math.round(bookingAmountData.amount * 100)
+ : baseFare;
+ const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
@@ -248,7 +263,12 @@ export default function PaymentPage() {
Total
- {displayCurrency} {(totalAmount / 100).toFixed(2)}
+
+ {loadingAmount && (
+
+ )}
+ {confirmedCurrency} {(totalAmount / 100).toFixed(2)}
+
@@ -259,15 +279,19 @@ export default function PaymentPage() {
)}