diff --git a/apps/edr-freight-api/html/payment-tester.html b/apps/edr-freight-api/html/payment-tester.html new file mode 100644 index 000000000..1bb1a5c80 --- /dev/null +++ b/apps/edr-freight-api/html/payment-tester.html @@ -0,0 +1,452 @@ + + + + + +EDR Freight β€” Payment Tester (Telebirr ETB + Card USD) + + + +
+

πŸš‚ EDR Freight β€” Payment Tester

+ Telebirr (ETB) + Card (USD) +
+ +
+ +
+

API connection

+
+
+ + +
+
+ + +
+
+
+ + not checked +
+
+ + +
+

1 Β· Choose a booking

+
+
+ + +
+ +
+
+ + Currency (ETB vs USD) is set per-booking via paymentCurrency. Pick an ETB booking to test Telebirr, a USD booking to test Card. +
+
+ + +
+ + +
+

2 Β· Initiate payment

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ Telebirr β†’ forces method TELEBIRR. Card β†’ forces method CARD. + Each calls POST {base}/payments/initiate and follows the returned clientAction (REDIRECT url for web). +
+

+
+ + +
+

3 Β· Track intent & receipt

+
+ + + no intent yet +
+
+ + Receipt = GET {base}/payments/receipt/{merchantOrderId} +
+
+ + +
+
+

Last response

+
β€”
+
+
+

Request log

+
+
+
+
+ + + + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 134bfd885..71e2fd60a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -25,12 +25,14 @@ "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", + "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js" + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9026a4dbc..db05ae26f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; +import { EventEmitterModule } from "@nestjs/event-emitter"; import { DataSource, DataSourceOptions } from "typeorm"; import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; @@ -56,6 +57,7 @@ import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-inte import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; @@ -79,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], }), ScheduleModule.forRoot(), - // EventEmitterModule.forRoot(), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => @@ -144,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, DemoFreightDataSeeder, + GovCompaniesSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, Batch5TestDataSeeder, @@ -173,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { @@ -199,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap { // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval // rules are disabled inside the seeder). Kept running for the staff users. await this.demoFreightDataSeeder.run(); + // Government entities (with importer/exporter profiles) that government + // bookings bill to. Idempotent β€” keyed by fixed IDs. + await this.govCompaniesSeeder.run(); } } diff --git a/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts new file mode 100644 index 000000000..b2026b753 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.last_mile_container_allocations table β€” container allocation + * records linking last-mile deliveries with containers and vehicles. + */ +export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'last_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts new file mode 100644 index 000000000..aeedae2b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1810000000004'; + + public async up(queryRunner: QueryRunner): Promise { + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.first_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } + + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.last_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + } + + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts new file mode 100644 index 000000000..93196578d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -0,0 +1,107 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Freight billing β€” `invoices` + `invoice_lines` tables. + * + * Matches: + * - billing/entities/invoice.entity.ts + * - billing/entities/invoice-line.entity.ts + * + * The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default + * enum-type name (`__enum`) so the entity's `type: "enum"` + * column resolves to it without an explicit `enumName`. + */ +export class CreateInvoices1821000000002 implements MigrationInterface { + name = "CreateInvoices1821000000002"; + + public async up(queryRunner: QueryRunner): Promise { + const typeExists = await queryRunner.query( + `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`, + ); + + if (!typeExists.length) { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + } + + await queryRunner.query(` + CREATE TABLE freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + + await queryRunner.query(` + CREATE TABLE freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts new file mode 100644 index 000000000..95ea3db1b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts @@ -0,0 +1,129 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Government bookings now bill to a real seeded government company + an explicit + * importer/exporter profile, instead of carrying a null company + free-text + * institution. This migration: + * + * 1. Adds `companies.kind` (commercial | government). + * 2. Seeds the Ethiopian government entities + their importer/exporter + * profiles (mirrors src/seed/data/gov-companies.data.ts β€” keep in sync). + * 3. Backfills every booking with a NULL company_id / company_profile_id so + * the NOT NULL constraints below can be applied: + * - NULL company_id β†’ the default government company. + * - NULL company_profile_id β†’ the company's profile matching the booking + * trade direction; else any profile of the company; else the default + * government importer profile. + * 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id. + */ +export class AddCompanyKindAndGovBookingLinks1821000000003 + implements MigrationInterface +{ + name = "AddCompanyKindAndGovBookingLinks1821000000003"; + + // Mirrors src/seed/data/gov-companies.data.ts + private readonly govCompanies = [ + { id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" }, + { id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" }, + { id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" }, + { id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" }, + { id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" }, + { id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" }, + ]; + + private get defaultCompanyId(): string { + return this.govCompanies[0].id; + } + private get defaultImporterProfileId(): string { + return this.govCompanies[0].im; + } + + public async up(queryRunner: QueryRunner): Promise { + // 1. kind column + await queryRunner.query( + `ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`, + ); + + // 2. seed government companies + importer/exporter profiles (idempotent) + for (const g of this.govCompanies) { + await queryRunner.query( + `INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone") + VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5) + ON CONFLICT ("id") DO NOTHING`, + [g.id, g.name, g.tin, g.email, g.phone], + ); + await queryRunner.query( + `INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status") + VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active') + ON CONFLICT ("id") DO NOTHING`, + [g.im, g.id, g.imRef, g.ex, g.exRef], + ); + } + + // 3a. backfill NULL company_id β†’ default government company + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`, + [this.defaultCompanyId], + ); + + // 3b. backfill NULL company_profile_id β†’ profile matching trade direction + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = cp."id" + FROM "freight"."company_profiles" cp + WHERE b."company_profile_id" IS NULL + AND cp."company_id" = b."company_id" + AND cp."deleted_at" IS NULL + AND cp."type" = CASE b."trade_direction" + WHEN 'IMPORT' THEN 'importer' + WHEN 'EXPORT' THEN 'exporter' + ELSE NULL END`, + ); + + // 3c. fallback β†’ any profile of the booking's company + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = ( + SELECT cp."id" FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL + ORDER BY cp."created_at" ASC LIMIT 1) + WHERE b."company_profile_id" IS NULL + AND EXISTS ( + SELECT 1 FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`, + ); + + // 3d. final fallback β†’ default government importer profile + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`, + [this.defaultImporterProfileId], + ); + + // 4. enforce NOT NULL + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`, + ); + // Seeded government rows are intentionally left in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts new file mode 100644 index 000000000..9f0e8e7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make the payment projection source-agnostic so any domain (not just bookings) + * can own a payment intent. + * + * - `payments.type` enum `('booking')` β†’ `varchar(50)`. It now stores the + * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a + * new domain no longer needs an enum migration to write its intents. + * - adds `payments.reference_type varchar(40)` β€” the gateway reference type + * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll + * path can query the provider without hardcoding it. + * + * Matches payment/entities/payment.entity.ts. + */ +export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface { + name = "MakePaymentsTypeGeneric1821000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`, + ); + await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`); + + await queryRunner.query( + `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`, + ); + + // Restore the single-value enum. Any non-'booking' rows would block the cast; + // collapse them first so the down migration is safe. + await queryRunner.query( + `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`, + ); + await queryRunner.query( + `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`, + ); + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts new file mode 100644 index 000000000..70b0832f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.booking_container_allocations table β€” container-to-vehicle + * allocation mapping for flexible routing of containers across available vehicles. + */ +export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface { + name = 'CreateBookingContainerAllocations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.booking_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.booking_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts new file mode 100644 index 000000000..b91e88633 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.first_mile_container_allocations table β€” tracks + * container allocations per first-mile shipment with optional vehicle assignment. + */ +export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.first_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'first_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { name: 'container_type', type: 'text', isNullable: false }, + { + name: 'quantity', + type: 'int', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['first_mile_id'], + referencedTableName: 'freight.first_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.first_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5a801cf73..e954b7e1b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -8,7 +8,7 @@ import { BillingService } from "./billing.service"; @Controller("billing") @FreightAdmin() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor(private readonly billingService: BillingService) { } @Get("invoices") @ApiOperation({ summary: "List all invoices" }) @@ -16,9 +16,9 @@ export class BillingController { return this.billingService.findAll(); } - @Get("invoices/booking/:bookingId") - @ApiOperation({ summary: "List invoices for a booking" }) - findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { - return this.billingService.findByBooking(bookingId); + @Get("invoices/:id") + @ApiOperation({ summary: "Get an invoice with its line items" }) + findById(@Param("id", ParseUUIDPipe) id: string) { + return this.billingService.findById(id); } } diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 2b16b1515..551fae6bf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -1,14 +1,24 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; +import { PortalBillingController } from "./portal-billing.controller"; import { BillingService } from "./billing.service"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { PaymentModule } from "../payment/payment.module"; +import { CompaniesModule } from "../companies/companies.module"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice])], - controllers: [BillingController], - providers: [BillingService], + imports: [ + TypeOrmModule.forFeature([Invoice, InvoiceLine]), + forwardRef(() => PaymentModule), + CompaniesModule, + ], + controllers: [BillingController, PortalBillingController], + providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) export class BillingModule {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts new file mode 100644 index 000000000..0e6d97de0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -0,0 +1,250 @@ +import { Freight } from "@edr/types"; + +import { BillingService } from "./billing.service"; + +/** + * Minimal in-memory EntityManager stand-in covering the methods + * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager. + */ +function makeManager(savedLines: unknown[]) { + return { + create: (_entity: unknown, data: Record) => data, + save: (data: Record) => { + const row = { id: data.id ?? "gen-1", ...data }; + if (data.invoiceId) savedLines.push(row); + return Promise.resolve(row); + }, + query: () => Promise.resolve([{ seq: 0 }]), + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue(null), + }; +} + +function makeEvents() { + return { emit: jest.fn() }; +} + +function generateInput(overrides: Record = {}) { + return { + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "prepaid", + companyId: "company-1", + companyProfileId: "profile-1", + currency: "ETB", + lines: [ + { + chargeType: "RAIL_FREIGHT", + description: "Rail freight", + quantity: 2, + unitRate: 500, + amount: 1000, + }, + { + chargeType: "HAZARD_SURCHARGE", + description: "Hazard surcharge", + quantity: 2, + unitRate: 250, + amount: 500, + }, + ], + ...overrides, + }; +} + +describe("BillingService.generateInvoice", () => { + let savedLines: unknown[]; + let manager: ReturnType; + let events: ReturnType; + let dataSource: { transaction: jest.Mock; manager: unknown }; + let service: BillingService; + + beforeEach(() => { + savedLines = []; + manager = makeManager(savedLines); + events = makeEvents(); + dataSource = { + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + service = new BillingService( + dataSource as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + }); + + it("creates a PENDING invoice with one line per input line", async () => { + const invoice = await service.generateInvoice(generateInput()); + + expect(invoice.status).toBe(Freight.InvoiceStatus.Pending); + expect(invoice.companyId).toBe("company-1"); + expect(invoice.source).toBe("booking"); + expect(invoice.sourceId).toBe("booking-1"); + expect(invoice.totalAmount).toBe(1500); + expect(invoice.issuedAt).toBeInstanceOf(Date); + expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(savedLines).toHaveLength(2); + }); + + it("sums line amounts when no explicit totalAmount is given", async () => { + const invoice = await service.generateInvoice( + generateInput({ totalAmount: undefined }), + ); + expect(invoice.totalAmount).toBe(1500); + }); + + it("leaves issuedAt null for a DRAFT invoice", async () => { + const invoice = await service.generateInvoice( + generateInput({ status: Freight.InvoiceStatus.Draft }), + ); + expect(invoice.status).toBe(Freight.InvoiceStatus.Draft); + expect(invoice.issuedAt).toBeNull(); + }); + + it("enlists in a caller's transaction when a manager is passed", async () => { + await service.generateInvoice(generateInput(), manager as never); + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect(savedLines).toHaveLength(2); + }); +}); + +describe("BillingService.markInvoiceAsPaid", () => { + it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.objectContaining({ + invoiceId: "inv-1", + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + }), + ); + }); + + it("is a no-op (no event) when the invoice is already paid", async () => { + const paid = { + id: "inv-1", + status: Freight.InvoiceStatus.Paid, + source: "booking", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(paid), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); + +describe("BillingService.settlePayable", () => { + it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.anything(), + ); + }); + + it("is a no-op (returns null) when the source has no open invoice", async () => { + const mg = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled).toBeNull(); + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39eae6ef5..01b057a76 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,26 +1,542 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { Freight, PaymentReferenceType } from "@edr/types"; +import { DataSource, EntityManager, In } from "typeorm"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; +import { CompaniesService } from "../companies/companies.service"; + +/** Options forwarded to the payment gateway when settling an invoice. */ +export interface PayInvoiceOptions { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; +} + +/** Default invoice payment-term window, in days, used to compute `dueAt`. */ +const DEFAULT_DUE_DAYS = 14; + +/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ +const OPEN_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.Overdue, +]; + +/** A single line to bill on a generated invoice. */ +export interface InvoiceLineInput { + chargeType: string; + description?: string; + /** Units this line bills for; defaults to 1. */ + quantity?: number; + /** Price per unit; defaults to 0. */ + unitRate?: number; + /** Line total; defaults to `quantity * unitRate`. */ + amount?: number; + currency?: string; + metadata?: Record | null; +} + +/** Everything needed to generate an invoice for any source. */ +export interface GenerateInvoiceInput { + /** Originating subsystem; namespaces events (`${source}.invoice.`). */ + source: Freight.InvoiceSource; + /** Identifier of the source record (e.g. booking id). */ + sourceId: string; + /** What the invoice is for (e.g. "prepaid", "credit"). */ + type: string; + companyId: string; + companyProfileId: string; + lines: InvoiceLineInput[]; + currency?: string; + /** Explicit total; defaults to the sum of line amounts. */ + totalAmount?: number; + /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ + dueAt?: Date; + dueInDays?: number; + /** + * Initial status. DRAFT leaves `issuedAt` null; any issued status + * (default PENDING) stamps `issuedAt`. + */ + status?: Freight.InvoiceStatus; +} + +/** Payload broadcast on `${source}.invoice.`. */ +export interface InvoiceEventPayload { + invoiceId: string; + invoiceNumber: string; + source: Freight.InvoiceSource; + sourceId: string; + type: string; + companyId: string; + companyProfileId: string; + totalAmount: number; + currency: string; + status: Freight.InvoiceStatus; + paymentId?: string | null; +} @Injectable() export class BillingService { + private readonly logger = new Logger(BillingService.name); + constructor( - @InjectRepository(Invoice) - private readonly invoicesRepository: Repository, - ) {} + private readonly dataSource: DataSource, + private readonly invoices: InvoiceRepository, + private readonly invoiceLines: InvoiceLineRepository, + private readonly events: EventEmitter2, + @Inject(forwardRef(() => PaymentService)) + private readonly payment: PaymentService, + private readonly companies: CompaniesService, + ) { } + + // ── Reads ────────────────────────────────────────────────────────────────── /** List every invoice (most recent first). */ findAll(): Promise { - return this.invoicesRepository.find({ order: { issuedAt: "DESC" } }); + return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } - /** List invoices for a given booking. */ - findByBooking(bookingId: string): Promise { - return this.invoicesRepository.find({ - where: { bookingId }, + /** Invoice header plus its line items. */ + async findById(id: string): Promise { + const invoice = await this.invoices.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const lines = await this.invoiceLines.findAll({ + where: { invoiceId: id }, + order: { createdAt: "ASC" }, + }); + return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + } + + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── + + /** Resolve the customer's company id from their IAM user id (null if none). */ + async resolveCompanyId(userId: string): Promise { + try { + const { company } = await this.companies.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** Every invoice billed to a company, newest first, with billing relations. */ + findByCompany(companyId: string): Promise { + return this.invoices.findAll({ + where: { companyId }, + relations: { company: true, companyProfile: true }, + order: { createdAt: "DESC" }, + }); + } + + /** Invoices for the signed-in customer; empty when they have no company. */ + async findForUser(userId: string): Promise { + const companyId = await this.resolveCompanyId(userId); + return companyId ? this.findByCompany(companyId) : []; + } + + /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + async findByIdForUser( + id: string, + userId: string, + ): Promise { + const companyId = await this.resolveCompanyId(userId); + const invoice = await this.findById(id); + if (!companyId || invoice.companyId !== companyId) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + /** + * Initiate gateway payment for one of the customer's own invoices. Verifies + * ownership, then charges whichever open invoice the source currently has + * (see {@link payInvoice}). + */ + async payInvoiceForUser( + id: string, + userId: string, + opts: PayInvoiceOptions = {}, + ): Promise { + const invoice = await this.findByIdForUser(id, userId); + return this.payInvoice( + invoice.source as Freight.InvoiceSource, + invoice.sourceId, + opts, + ); + } + + // ── Generation ─────────────────────────────────────────────────────────────── + + /** `FRT-YYYYMMDD-00001` β€” sequential per day, within the active transaction. */ + private async nextInvoiceNumber(mg: EntityManager): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `FRT-${ymd}-`; + const [row] = await mg.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; + } + + /** + * Generate an invoice for any source (booking, demurrage, manual, …). + * + * Persists the header plus its lines in one transaction and assigns the next + * sequential `invoice_number`. The total defaults to the sum of line amounts + * unless `totalAmount` is given. Issued invoices (default PENDING) stamp + * `issuedAt`; pass `status: DRAFT` to leave it unissued. + * + * Pass `manager` to enlist in a caller's transaction (e.g. when generating an + * invoice as part of a larger booking flow). + */ + async generateInvoice( + input: GenerateInvoiceInput, + manager?: EntityManager, + ): Promise { + const run = (mg: EntityManager) => this.createInvoice(input, mg); + return manager ? run(manager) : this.dataSource.transaction(run); + } + + private async createInvoice( + input: GenerateInvoiceInput, + mg: EntityManager, + ): Promise { + const currency = input.currency ?? "ETB"; + const status = input.status ?? Freight.InvoiceStatus.Pending; + const issued = status !== Freight.InvoiceStatus.Draft; + + const lines = input.lines.map((l) => { + const quantity = l.quantity ?? 1; + const unitRate = l.unitRate ?? 0; + return { + chargeType: l.chargeType, + description: l.description, + quantity, + unitRate, + amount: l.amount ?? quantity * unitRate, + currency: l.currency ?? currency, + metadata: l.metadata ?? null, + }; + }); + + const totalAmount = + input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + + const dueAt = + input.dueAt ?? + new Date( + Date.now() + + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + ); + + const invoiceNumber = await this.nextInvoiceNumber(mg); + + const invoice = await mg.save( + mg.create(Invoice, { + invoiceNumber, + source: input.source, + sourceId: input.sourceId, + type: input.type, + companyId: input.companyId, + companyProfileId: input.companyProfileId, + totalAmount, + currency, + status, + issuedAt: issued ? new Date() : null, + dueAt, + }), + ); + + const savedLines = await Promise.all( + lines.map((l) => + mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })), + ), + ); + + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`, + ); + + return { ...invoice, lines: savedLines }; + } + + // ── State transitions ──────────────────────────────────────────────────────── + + /** + * Mark an invoice paid and link the gateway payment, then emit + * `${source}.invoice.paid`. Full-payment only β€” no partial settlement. + * No-op when the invoice is already paid. Pass `manager` to enlist in a + * caller's transaction. + */ + async markInvoiceAsPaid( + invoiceId: string, + paymentId: string | null = null, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Paid, + "paid", + { paymentId: paymentId ?? undefined }, + manager, + ); + } + + /** + * Mark an invoice refunded and emit `${source}.invoice.refunded`. + * No-op when already refunded. + */ + async markInvoiceAsRefunded( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Refunded, + "refunded", + {}, + manager, + ); + } + + /** + * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. + * No-op when already cancelled. + */ + async cancelInvoice( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Cancelled, + "cancelled", + {}, + manager, + ); + } + + /** + * Load the invoice, apply the new status (+ extra columns), then emit + * `${source}.invoice.`. No-op (returns the invoice) when it is already + * in the target status. Throws when the invoice does not exist. + * + * Note: the event fires in-process synchronously. When a `manager` from an + * outer transaction is passed, listeners run before that transaction commits. + */ + private async transition( + invoiceId: string, + status: Freight.InvoiceStatus, + event: string, + extra: { paymentId?: string }, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + if (invoice.status === status) return invoice; + + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + + const updated = { ...invoice, ...extra, status } as Invoice; + this.emitInvoiceEvent(event, updated); + return updated; + } + + /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ + private emitInvoiceEvent(event: string, invoice: Invoice): void { + const payload: InvoiceEventPayload = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source as Freight.InvoiceSource, + sourceId: invoice.sourceId, + type: invoice.type, + companyId: invoice.companyId, + companyProfileId: invoice.companyProfileId, + totalAmount: invoice.totalAmount, + currency: invoice.currency, + status: invoice.status, + paymentId: invoice.paymentId ?? null, + }; + this.events.emit(`${invoice.source}.invoice.${event}`, payload); + } + + // ── Payment reconciliation (by source) ─────────────────────────────────────── + + /** + * The invoice a gateway payment should settle for a source record, or null if + * none. This is the billing document of record for "what is owed" β€” callers + * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than + * recomputing from the source's own total, so discounts/penalties/adjustments + * carried on the invoice are honored. + * + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching open (unpaid, + * non-cancelled) invoice. + */ + findPayable( + source: Freight.InvoiceSource, + sourceId: string, + type?: string, + ): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); } + + /** + * Settle a source's currently-open invoice as paid and link the gateway + * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then + * delegates to {@link markInvoiceAsPaid}. Full-payment only β€” no partial + * settlement. No-op (returns null) when the source has no open invoice. + * + * Type-blind by design: settles whichever invoice is due; any per-type reaction + * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`. + * Pass the caller's transaction `manager` to enlist in its DB transaction. + * + * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` + * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper + * for callers that settle by source rather than by gateway intent id. + */ + async settlePayable( + source: Freight.InvoiceSource, + sourceId: string, + paymentId: string | null, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId, mg); + } + + /** + * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. + * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. + * No-op (returns null) when the source has no paid invoice. + * + * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) + * to enlist in its DB transaction. + */ + async refundPayable( + source: Freight.InvoiceSource, + sourceId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsRefunded(invoice.id, mg); + } + + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── + + /** + * Charge a source's open invoice through the payment gateway. Billing is the + * single place that turns "what is owed" (the invoice) into a payment intent β€” + * the domain never talks to the payment service directly. Resolves the open + * invoice, opens an intent for `invoice.totalAmount`, records the intent id on + * the invoice (the settlement correlation key), and returns the client action. + * + * When the provider settles synchronously, the invoice is settled inline here β€” + * after the intent id is stored β€” so the `payment.succeeded` correlation can + * never fire before the link exists. Throws when the source has no open invoice. + */ + async payInvoice( + source: Freight.InvoiceSource, + sourceId: string, + opts: { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + } = {}, + ): Promise { + const invoice = await this.findPayable(source, sourceId); + if (!invoice) { + throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + } + + const result = await this.payment.initiate({ + referenceId: sourceId, + source: invoice.source, + // Gateway reference type derives from the invoice source by convention + // (source.toUpperCase() ∈ PaymentReferenceType) β€” no domain word here, and + // the domain never supplies it. New sources add their uppercased value to + // the PaymentReferenceType enum. + referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + orderRef: invoice.invoiceNumber, + amountMinor: Math.round(Number(invoice.totalAmount)), + currency: invoice.currency, + reason: `Payment for invoice ${invoice.invoiceNumber}`, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + payerAccount: opts.payerAccount, + returnUrl: opts.returnUrl, + failureUrl: opts.failureUrl, + }); + + // Link the intent to the invoice BEFORE any settlement can correlate against it. + await this.dataSource + .getRepository(Invoice) + .update({ id: invoice.id }, { paymentId: result.intentId }); + + if (result.immediateSuccess) { + await this.settleByPaymentId( + result.intentId, + result.providerTxnId, + result.paidAt, + ); + } + + return result.response; + } + + /** + * Settle the open invoice linked to a gateway intent id, if any. Called by the + * payment service when an intent succeeds: finds the invoice linked by + * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain + * to advance on. Idempotent β€” no-op when no open invoice is linked (already + * settled, or settled inline by {@link payInvoice}). + */ + async settleByPaymentId( + paymentId: string, + _providerTxnId?: string, + _paidAt?: Date, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { paymentId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId); + } } diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts new file mode 100644 index 000000000..c29160ab7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +/** Gateway options for paying an invoice from the customer portal. */ +export class PayInvoiceDto { + @ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." }) + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: "web" | "mobile"; + + @ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." }) + @IsOptional() + @IsString() + payerAccount?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on success." }) + @IsOptional() + @IsString() + returnUrl?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on failure." }) + @IsOptional() + @IsString() + failureUrl?: string; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts new file mode 100644 index 000000000..a042dedb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "./invoice.entity"; + +@Entity({ schema: "freight", name: "invoice_lines" }) +export class InvoiceLine extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid", nullable: false }) + invoiceId!: string; + + @ManyToOne(() => Invoice, { onDelete: "CASCADE" }) + @JoinColumn({ name: "invoice_id" }) + invoice!: Invoice; + + @Column({ name: "charge_type", type: "varchar", nullable: false }) + chargeType!: string; + + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string; + + /** Units this line bills for (e.g. container count, wagon count, tons). */ + @Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 }) + quantity!: number; + + /** Price per unit; `amount` is normally `quantity * unitRate`. */ + @Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ + name: "amount", + type: "numeric", + precision: 14, + scale: 2, + nullable: false, + }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ name: "metadata", type: "jsonb", nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index e2a6f7cc2..61bc9c16b 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,17 +1,35 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { PaymentEntity } from "../../payment/entities/payment.entity"; +import { Company } from "../../companies/entities/company.entity"; +import { CompanyProfile } from "../../companies/entities/company-profile.entity"; -@Entity({schema:"freight", name: "invoices" }) +@Entity({ schema: "freight", name: "invoices" }) +@Index(["companyId"]) +@Index(["companyProfileId"]) export class Invoice extends BaseEntity { - @Column({ name: "booking_id", type: "uuid" }) - bookingId!: string; - @Column({ name: "invoice_number", type: "varchar", length: 64, unique: true }) invoiceNumber!: string; - @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) - amount!: number; + /** The customer (company) this invoice is billed to. */ + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** The specific company profile (importer/exporter/forwarder/...) billed. */ + @Column({ name: "company_profile_id", type: "uuid" }) + companyProfileId!: string; + + @ManyToOne(() => CompanyProfile) + @JoinColumn({ name: "company_profile_id" }) + companyProfile?: CompanyProfile; + + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) + totalAmount!: number; @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -19,13 +37,38 @@ export class Invoice extends BaseEntity { @Column({ name: "status", type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + enum: Freight.InvoiceStatus, + default: Freight.InvoiceStatus.Draft, }) - status!: Freight.PaymentStatus; + status!: Freight.InvoiceStatus; - @Column({ name: "issued_at", type: "timestamptz" }) - issuedAt!: Date; + /** The source of the payment (e.g. booking, customer, etc.). */ + @Column({ name: "source", type: "varchar", length: 255, nullable: false }) + source!: string; + + /** The ID of the source (e.g. booking ID, customer ID, etc.). */ + @Column({ name: "source_id", type: "varchar", length: 255, nullable: false }) + sourceId!: string; + + /** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */ + @Column({ + type: "varchar", + length: 255, + nullable: false, + }) + type!: string; + + /** Set when the invoice is actually issued (DRAFT invoices leave this null). */ + @Column({ name: "issued_at", type: "timestamptz", nullable: true }) + issuedAt?: Date | null; + + /** The ID of the payment that generated this invoice. */ + @Column({ name: "payment_id", type: "uuid", nullable: true }) + paymentId?: string | null; + + @ManyToOne(() => PaymentEntity) + @JoinColumn({ name: "payment_id" }) + payment?: PaymentEntity; @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; diff --git a/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts new file mode 100644 index 000000000..6a5482543 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { InvoiceLine } from "./entities/invoice-line.entity"; + +@Injectable() +export class InvoiceLineRepository extends BaseRepository { + constructor( + @InjectRepository(InvoiceLine) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts new file mode 100644 index 000000000..cc2e89df7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Invoice } from "./entities/invoice.entity"; + +@Injectable() +export class InvoiceRepository extends BaseRepository { + constructor( + @InjectRepository(Invoice) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts new file mode 100644 index 000000000..5a007c320 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; + +import { + type AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { BillingService } from "./billing.service"; +import { PayInvoiceDto } from "./dto/pay-invoice.dto"; + +/** + * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, + * org-wide), every route here is force-scoped to the signed-in customer's + * company β€” they only ever see and pay their own invoices. + */ +@ApiTags("billing") +@ApiBearerAuth() +@Controller("billing") +export class PortalBillingController { + constructor(private readonly billingService: BillingService) {} + + @Get("my-invoices") + @ApiOperation({ summary: "List the signed-in customer's invoices" }) + findMine(@CurrentUser() user: AuthUserPayload) { + return this.billingService.findForUser(resolveAuthUserId(user)); + } + + @Get("my-invoices/:id") + @ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" }) + findMineById( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); + } + + @Post("my-invoices/:id/pay") + @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) + pay( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: PayInvoiceDto, + ) { + return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), { + method: dto.method, + platform: dto.platform ?? "web", + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts new file mode 100644 index 000000000..cfb9887c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingsService } from './bookings.service'; +import { AllocateContainersDto } from './dto/allocate-containers.dto'; + +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() +export class BookingAllocationController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post(':bookingId/allocate-containers') + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AllocateContainersDto, + ) { + return this.bookingsService.allocateContainers(bookingId, dto.allocations); + } +} 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… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + 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 ded7d0239..5d7e3b2c9 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'; @@ -28,12 +32,12 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; 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({ @@ -47,8 +51,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), - PaymentModule, + BillingModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, @@ -63,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, PayController, BookingPaymentController], providers: [ BookingsService, BookingsRepository, @@ -72,6 +78,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingPricingService, BookingTransitionService, BookingContractService, + BookingInvoiceService, BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, @@ -79,6 +86,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 3d3bab20b..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'; @@ -43,6 +43,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -307,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( @@ -383,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 { @@ -413,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) @@ -443,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, @@ -1305,4 +1338,35 @@ export class BookingsService { createdAt: b.createdAt, })); } + + async allocateContainers( + bookingId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const booking = await this.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + }); + await manager.insert(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..8b9b7da39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class ContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateContainersDto { + allocations!: ContainerAllocationDto[]; +} 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-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts new file mode 100644 index 000000000..8cb186e09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'booking_container_allocations' }) +@Index(['bookingId']) +@Index(['vehicleId']) +export class BookingContainerAllocation extends BaseEntity { + @ManyToOne(() => Booking, (b) => b.containerAllocations) + @JoinColumn({ name: 'booking_id' }) + booking!: Booking; + + @Column('uuid', { name: 'booking_id' }) + bookingId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string; + + @Column('text') + containerType!: string; // CONTAINER, BULK_DRY, etc + + @Column('integer', { default: 1 }) + quantity!: number; +} 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 ac7fe636a..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 @@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity'; import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; +import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; @@ -105,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' }) @@ -116,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' }) @@ -441,6 +445,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; + @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) + containerAllocations?: BookingContainerAllocation[]; + @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; 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/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..b750f1147 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class FirstMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateFirstMileContainersDto { + allocations!: FirstMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts new file mode 100644 index 000000000..b0fa54c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { FirstMile } from './first-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'first_mile_container_allocations', schema: 'freight' }) +@Index(['firstMileId']) +@Index(['vehicleId']) +export class FirstMileContainerAllocation extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, { + nullable: false, + eager: false, + }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column({ name: 'container_type', type: 'text' }) + containerType!: string; + + @Column({ name: 'quantity', type: 'int', default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 17ed1c101..253d2d4c8 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -34,6 +35,10 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + // TODO: uncomment after migration creates column + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; @@ -46,4 +51,11 @@ export class FirstMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany( + () => FirstMileContainerAllocation, + (containerAllocation) => containerAllocation.firstMile, + { eager: false }, + ) + containerAllocations!: FirstMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts new file mode 100644 index 000000000..c63a4c9e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FirstMileRepository } from './first-mile.repository'; +import { FirstMile } from './entities/first-mile.entity'; + +/** + * Owns the first-mile ⇄ invoice mapping β€” the one place that knows how a + * first-mile record turns into invoices, which type to use, and how it + * advances when paid. First-mile records are billable entities, so they + * generate their own invoices directly via {@link BillingService}. + */ +@Injectable() +export class FirstMileInvoiceService { + private readonly logger = new Logger(FirstMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly firstMileRepo: FirstMileRepository, + ) {} + + /** + * Ensure the first-mile record has its invoice, generating one from the + * remaining payment if absent. Called when a first-mile record reaches a + * billable state. Idempotent β€” returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill. + */ + async ensureInvoiceFor(record: FirstMile): Promise { + const existing = await this.billing.findPayable( + 'first_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + if (!record.bookingId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no booking to reference.`, + ); + return null; + } + + // Fetch the booking to get the companyId and companyProfileId + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + if (!fm) return null; + if (!fm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + const totalAmount = record.remainingPayment || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + + return this.billing.generateInvoice({ + source: 'first_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: fm.booking!.companyId, + companyProfileId: fm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'First-mile delivery', + quantity: 1, + unitRate: totalAmount, + amount: totalAmount, + }, + ], + totalAmount, + }); + } + + /** + * React to a first-mile invoice being paid β€” the settlement branch point. + * Mark the first-mile record as having completed post-payment processing. + */ + @OnEvent('first_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.firstMileRepo.findById(payload.sourceId); + if (!record) { + this.logger.warn( + `Cannot mark unknown first-mile record ${payload.sourceId} as paid.`, + ); + return; + } + + this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 78c3d43ff..6bb307a4b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; @ApiTags('first-mile') @ApiBearerAuth() @Controller('first-mile') @TrainSchedulingView() export class FirstMileController { - constructor(private readonly firstMileService: FirstMileService) {} + constructor( + private readonly firstMileService: FirstMileService, + private readonly firstMileInvoiceService: FirstMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -72,8 +77,13 @@ export class FirstMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - return this.firstMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { + const record = await this.firstMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.firstMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') @@ -83,4 +93,14 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } + + @Post(':firstMileId/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) + allocateContainers( + @Param('firstMileId', ParseUUIDPipe) firstMileId: string, + @Body() dto: AllocateFirstMileContainersDto, + ) { + return this.firstMileService.allocateContainers(firstMileId, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index bf6815af7..a69c920f1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,25 +1,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [FirstMileController], - providers: [FirstMileRepository, FirstMileService], - exports: [FirstMileRepository, FirstMileService], + providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], + exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], }) export class FirstMileModule {} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a06f9eb87..08cd9ab10 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,13 +1,16 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; type FirstMileListFilter = { @@ -32,11 +35,12 @@ export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( + @InjectDataSource() private readonly dataSource: DataSource, private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} /** @@ -251,16 +255,19 @@ export class FirstMileService { const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.firstMilePickupAddress, - destinationYard: booking?.originYard?.label, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + + (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } @@ -270,4 +277,35 @@ export class FirstMileService { await this.findById(id); await this.firstMileRepository.softDelete(id); } + + async allocateContainers( + firstMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const firstMile = await this.findById(firstMileId); + if (!firstMile) { + throw new NotFoundException(`First-mile record ${firstMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + }); + await manager.insert(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..de86ac883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class LastMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateLastMileContainersDto { + allocations!: LastMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts new file mode 100644 index 000000000..8a61c73bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { LastMile } from './last-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'last_mile_container_allocations' }) +@Index(['lastMileId']) +@Index(['vehicleId']) +export class LastMileContainerAllocation extends BaseEntity { + @ManyToOne(() => LastMile, (lm) => lm.containerAllocations) + @JoinColumn({ name: 'last_mile_id' }) + lastMile!: LastMile; + + @Column('uuid', { name: 'last_mile_id' }) + lastMileId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string | null; + + @Column('text') + containerType!: string; + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 3bfe2cd19..1747e308c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -34,6 +35,10 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + // TODO: uncomment after migration creates column + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; @@ -46,4 +51,7 @@ export class LastMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) + containerAllocations?: LastMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts new file mode 100644 index 000000000..c304a89e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { LastMileRepository } from './last-mile.repository'; +import { LastMile } from './entities/last-mile.entity'; + +/** + * Owns the last-mile ⇄ invoice mapping β€” the one place that knows how a last-mile + * record turns into invoices, which type to use, and how it advances when paid. + * Last-mile records are billable business entities for delivery fees, so they + * generate their own invoices directly via {@link BillingService}. All last-mile-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class LastMileInvoiceService { + private readonly logger = new Logger(LastMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly lastMileRepo: LastMileRepository, + ) {} + + /** + * Ensure the last-mile record has its invoice, generating one from the + * remainingPayment if absent. Called when a last-mile record reaches a + * billable state. Idempotent β€” returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill (invoices FK requires a companyId). + */ + async ensureInvoiceFor(record: LastMile): Promise { + // Check if invoice already exists + const existing = await this.billing.findPayable( + 'last_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + // Can't bill without company + const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } })); + if (!lm) return null; + if (!lm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + // Generate invoice with remainingPayment as totalAmount + const input: GenerateInvoiceInput = { + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'Last-mile delivery', + quantity: 1, + unitRate: record.remainingPayment || 0, + amount: record.remainingPayment || 0, + }, + ], + totalAmount: record.remainingPayment || 0, + }; + + return this.billing.generateInvoice(input); + } + + /** + * React to a last-mile invoice being paid β€” the settlement branch point. + * Advances the last-mile record to mark post-payment as completed. + */ + @OnEvent('last_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.lastMileRepo.findById(payload.sourceId); + if (record) { + this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`); + } else { + this.logger.warn( + `Cannot mark last-mile record ${payload.sourceId} as paid: not found.`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8abf52c6..929d97a3e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; @ApiTags('last-mile') @ApiBearerAuth() @Controller('last-mile') @TrainSchedulingView() export class LastMileController { - constructor(private readonly lastMileService: LastMileService) {} + constructor( + private readonly lastMileService: LastMileService, + private readonly lastMileInvoiceService: LastMileInvoiceService, + ) {} @Get() @ApiOperation({ summary: 'List last-mile legs' }) @@ -72,8 +77,13 @@ export class LastMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - return this.lastMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { + const record = await this.lastMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') @@ -83,4 +93,14 @@ export class LastMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.lastMileService.remove(id); } + + @Post(':id/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AllocateLastMileContainersDto, + ) { + return this.lastMileService.allocateContainers(id, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e4b99a18c..32b688069 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,25 +1,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [LastMileController], - providers: [LastMileRepository, LastMileService], - exports: [LastMileRepository, LastMileService], + providers: [LastMileRepository, LastMileService, LastMileInvoiceService], + exports: [LastMileRepository, LastMileService, LastMileInvoiceService], }) export class LastMileModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 2e8fb2463..5faad49b9 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,13 +1,14 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; type LastMileListFilter = { @@ -32,12 +33,12 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( - private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, + private readonly dataSource: DataSource, ) {} async acceptBooking(bookingReference: string): Promise { @@ -185,16 +186,19 @@ export class LastMileService { }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.destinationYard?.label, - destinationYard: booking?.lastMileDeliveryAddress, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a last-mile delivery. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') + + (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } @@ -204,4 +208,35 @@ export class LastMileService { await this.findById(id); await this.lastMileRepository.softDelete(id); } + + async allocateContainers( + lastMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const lastMile = await this.findById(lastMileId); + if (!lastMile) { + throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + }); + await manager.insert(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } 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-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index bcfa643b6..4c2ebe971 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -20,6 +20,7 @@ export class PaymentClientService { private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" + // "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; 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… - - - -
-
-

Redirecting to payment provider…

-

Click here if you are not redirected

-
- - -`; - } - - 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 dbd635d86..347fedc1e 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,65 +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", }); - 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, @@ -216,30 +272,37 @@ export class PaymentService { } return this.paymentRepo.create({ - refId: bookingId, - type: "booking", - amount: booking.totalAmount, - currency: booking.paymentCurrency, - reason: `Payment for booking ${booking.reference}`, + refId: input.referenceId, + type: input.source, + referenceType: input.referenceType, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, rawInitiation: snapshot as unknown as Record, 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`, ); } @@ -247,76 +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"); - } - - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async finalizePaymentSuccess(input: { - intentId: string; - bookingId: string; - providerTxnId?: string; - paidAt?: Date; - }): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent β€” no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === "success") return { alreadyFinalized: true }; - const paidAt = input.paidAt ?? new Date(); + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, + ); - // Every booking is a real shipment now (contracts are a separate aggregate), - // so payment always settles the booking to PAID and enters allocation. - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - ); - await mg.update( - Booking, - { id: input.bookingId }, - { paymentStatus: "PAID", status: "PAID" }, - ); - await this.firstMileService.acceptBooking(input.bookingId); - }); - - try { - await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); - } catch (err) { - this.logger.error( - `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, - ); + if (opts.notify !== false) { + await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); } return { alreadyFinalized: false }; @@ -335,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 { @@ -363,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 { @@ -398,24 +462,37 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: event.referenceId, + console.log(`Processing payment succeeded event for intent: }`,intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, }); + console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + + // When the intent references a booking, flip the booking itself paid. + // refId holds the booking id (the domain reference the intent opened with). + if (intent.referenceType === PaymentReferenceType.BOOKING) { + await this.datasource.manager.update( + Booking, + { id: intent.refId }, + { status: "PAID", paymentStatus: "PAID" }, + ); + } + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; } if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } await this.markPaymentFailed({ intentId: intent.id, 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/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx new file mode 100644 index 000000000..950cfd476 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface ContainerAllocationTableProps { + bookingId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for freight bookings. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function ContainerAllocationTable({ + bookingId, + containers, + onSave, +}: ContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} Β· ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + +
+ + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+ + + + + {allocatedCount} of {containers.length} containers allocated + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx new file mode 100644 index 000000000..85bba1dc4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface FirstMileContainerAllocationTableProps { + firstMileId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for first-mile pickups. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function FirstMileContainerAllocationTable({ + firstMileId, + containers, + onSave, +}: FirstMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} Β· ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx new file mode 100644 index 000000000..d11d99a4a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface LastMileContainerRow { + id: string; + type: string; + qty: number; +} + +export interface LastMileContainerAllocationTableProps { + lastMileId: string; + containers: LastMileContainerRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for last-mile deliveries. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function LastMileContainerAllocationTable({ + lastMileId, + containers, + onSave, +}: LastMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} Β· ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index 4ecfeebd5..bf01c4818 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -1,5 +1,7 @@ import { Container, Grid, Stack } from "@mantine/core"; import { useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import { BookingApprovalCard, @@ -16,10 +18,26 @@ import { type BookingDetailView, } from "@/components/bookings/detail"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import ContainerAllocationTable from "@/components/ContainerAllocationTable"; +import { api } from "@/services/api"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; const BookingDetailPage = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const qc = useQueryClient(); + + const allocateMutation = useMutation({ + mutationFn: (data: any) => + api.post(`/bookings/${id}/allocate-containers`, data), + onSuccess: () => { + toast.success("Containers allocated"); + qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") }); + }, + onError: () => { + toast.error("Failed to allocate containers"); + }, + }); // Mock data - replace with actual API call const booking: BookingDetailView = { @@ -134,6 +152,17 @@ const BookingDetailPage = () => { + ({ + id: c.id, + type: c.containerType?.label ?? "Unknown", + qty: c.quantity, + }))} + onSave={(allocations) => + allocateMutation.mutateAsync({ allocations }) + } + /> (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 - /> + + + ) : ( { - 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 ( - - {children} - - - - - Void invoice? - - - - This will void invoice{" "} - - {invoiceNumber} - - . This action cannot be undone. - - - - - - - - - - - - - - - ); -} 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 ( + + + + 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 ( + + + + + {/* Header */} + + + + {invoice.invoiceNumber} + + + + {payable && ( + + )} + + + {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 }} + /> + { + if (!v) return; + setPageSize(Number(v)); + setPageIndex(0); + }} + radius="md" + size="xs" + comboboxProps={{ withinPortal: true }} + style={{ width: 76 }} + allowDeselect={false} + /> + + {start}–{end} of {total} + + + + + } + 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 ( - - - {children ?? } - - - - - {title} - {description} - - -
- {/* Invoice Number */} -
- -
- - -
-
- - {/* Status */} -
- - -
- - {/* Customer */} -
- - -
- - {/* Booking */} -
- - -
- - {/* Amount */} -
- -
- - -
-
- - {/* Currency */} -
- - -
- - {/* Issue Date */} -
- -
- - -
-
- - {/* Due Date */} -
- -
- - -
-
- - {/* Notes */} -
- -