diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b0850737b..a9965c74a 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 -CMD ["node", "dist/main.js"] +CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 2fd9f6f5a..f8b2b27fe 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,13 +22,16 @@ "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", + "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", + "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js" + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index cc15d49ae..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"; @@ -53,8 +54,10 @@ import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; +import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; @@ -78,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], }), ScheduleModule.forRoot(), - // EventEmitterModule.forRoot(), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => @@ -143,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, DemoFreightDataSeeder, + GovCompaniesSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, Batch5TestDataSeeder, @@ -150,6 +154,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera Batch8TestDataSeeder, WarehouseDemoSeeder, ExportDjiboutiInterchangeDemoSeeder, + MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, ], }) @@ -168,8 +173,10 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { @@ -187,6 +194,7 @@ export class AppModule implements OnApplicationBootstrap { await this.batch8TestDataSeeder.run(); await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); + await this.marshallingDemoTrainsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, @@ -195,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap { // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval // rules are disabled inside the seeder). Kept running for the staff users. await this.demoFreightDataSeeder.run(); + // Government entities (with importer/exporter profiles) that government + // bookings bill to. Idempotent — keyed by fixed IDs. + await this.govCompaniesSeeder.run(); } } 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..5c42cad65 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -0,0 +1,101 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Freight billing — `invoices` + `invoice_lines` tables. + * + * Matches: + * - billing/entities/invoice.entity.ts + * - billing/entities/invoice-line.entity.ts + * + * The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default + * enum-type name (`__enum`) so the entity's `type: "enum"` + * column resolves to it without an explicit `enumName`. + */ +export class CreateInvoices1821000000002 implements MigrationInterface { + name = "CreateInvoices1821000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + + await queryRunner.query(` + CREATE TABLE freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + + await queryRunner.query(` + CREATE TABLE freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts new file mode 100644 index 000000000..95ea3db1b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts @@ -0,0 +1,129 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Government bookings now bill to a real seeded government company + an explicit + * importer/exporter profile, instead of carrying a null company + free-text + * institution. This migration: + * + * 1. Adds `companies.kind` (commercial | government). + * 2. Seeds the Ethiopian government entities + their importer/exporter + * profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync). + * 3. Backfills every booking with a NULL company_id / company_profile_id so + * the NOT NULL constraints below can be applied: + * - NULL company_id → the default government company. + * - NULL company_profile_id → the company's profile matching the booking + * trade direction; else any profile of the company; else the default + * government importer profile. + * 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id. + */ +export class AddCompanyKindAndGovBookingLinks1821000000003 + implements MigrationInterface +{ + name = "AddCompanyKindAndGovBookingLinks1821000000003"; + + // Mirrors src/seed/data/gov-companies.data.ts + private readonly govCompanies = [ + { id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" }, + { id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" }, + { id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" }, + { id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" }, + { id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" }, + { id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" }, + ]; + + private get defaultCompanyId(): string { + return this.govCompanies[0].id; + } + private get defaultImporterProfileId(): string { + return this.govCompanies[0].im; + } + + public async up(queryRunner: QueryRunner): Promise { + // 1. kind column + await queryRunner.query( + `ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`, + ); + + // 2. seed government companies + importer/exporter profiles (idempotent) + for (const g of this.govCompanies) { + await queryRunner.query( + `INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone") + VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5) + ON CONFLICT ("id") DO NOTHING`, + [g.id, g.name, g.tin, g.email, g.phone], + ); + await queryRunner.query( + `INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status") + VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active') + ON CONFLICT ("id") DO NOTHING`, + [g.im, g.id, g.imRef, g.ex, g.exRef], + ); + } + + // 3a. backfill NULL company_id → default government company + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`, + [this.defaultCompanyId], + ); + + // 3b. backfill NULL company_profile_id → profile matching trade direction + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = cp."id" + FROM "freight"."company_profiles" cp + WHERE b."company_profile_id" IS NULL + AND cp."company_id" = b."company_id" + AND cp."deleted_at" IS NULL + AND cp."type" = CASE b."trade_direction" + WHEN 'IMPORT' THEN 'importer' + WHEN 'EXPORT' THEN 'exporter' + ELSE NULL END`, + ); + + // 3c. fallback → any profile of the booking's company + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = ( + SELECT cp."id" FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL + ORDER BY cp."created_at" ASC LIMIT 1) + WHERE b."company_profile_id" IS NULL + AND EXISTS ( + SELECT 1 FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`, + ); + + // 3d. final fallback → default government importer profile + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`, + [this.defaultImporterProfileId], + ); + + // 4. enforce NOT NULL + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`, + ); + // Seeded government rows are intentionally left in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts new file mode 100644 index 000000000..9f0e8e7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make the payment projection source-agnostic so any domain (not just bookings) + * can own a payment intent. + * + * - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the + * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a + * new domain no longer needs an enum migration to write its intents. + * - adds `payments.reference_type varchar(40)` — the gateway reference type + * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll + * path can query the provider without hardcoding it. + * + * Matches payment/entities/payment.entity.ts. + */ +export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface { + name = "MakePaymentsTypeGeneric1821000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`, + ); + await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`); + + await queryRunner.query( + `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`, + ); + + // Restore the single-value enum. Any non-'booking' rows would block the cast; + // collapse them first so the down migration is safe. + await queryRunner.query( + `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`, + ); + await queryRunner.query( + `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`, + ); + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5a801cf73..e954b7e1b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -8,7 +8,7 @@ import { BillingService } from "./billing.service"; @Controller("billing") @FreightAdmin() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor(private readonly billingService: BillingService) { } @Get("invoices") @ApiOperation({ summary: "List all invoices" }) @@ -16,9 +16,9 @@ export class BillingController { return this.billingService.findAll(); } - @Get("invoices/booking/:bookingId") - @ApiOperation({ summary: "List invoices for a booking" }) - findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { - return this.billingService.findByBooking(bookingId); + @Get("invoices/:id") + @ApiOperation({ summary: "Get an invoice with its line items" }) + findById(@Param("id", ParseUUIDPipe) id: string) { + return this.billingService.findById(id); } } diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 2b16b1515..551fae6bf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -1,14 +1,24 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; +import { PortalBillingController } from "./portal-billing.controller"; import { BillingService } from "./billing.service"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { PaymentModule } from "../payment/payment.module"; +import { CompaniesModule } from "../companies/companies.module"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice])], - controllers: [BillingController], - providers: [BillingService], + imports: [ + TypeOrmModule.forFeature([Invoice, InvoiceLine]), + forwardRef(() => PaymentModule), + CompaniesModule, + ], + controllers: [BillingController, PortalBillingController], + providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) export class BillingModule {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts new file mode 100644 index 000000000..0e6d97de0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -0,0 +1,250 @@ +import { Freight } from "@edr/types"; + +import { BillingService } from "./billing.service"; + +/** + * Minimal in-memory EntityManager stand-in covering the methods + * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager. + */ +function makeManager(savedLines: unknown[]) { + return { + create: (_entity: unknown, data: Record) => data, + save: (data: Record) => { + const row = { id: data.id ?? "gen-1", ...data }; + if (data.invoiceId) savedLines.push(row); + return Promise.resolve(row); + }, + query: () => Promise.resolve([{ seq: 0 }]), + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue(null), + }; +} + +function makeEvents() { + return { emit: jest.fn() }; +} + +function generateInput(overrides: Record = {}) { + return { + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "prepaid", + companyId: "company-1", + companyProfileId: "profile-1", + currency: "ETB", + lines: [ + { + chargeType: "RAIL_FREIGHT", + description: "Rail freight", + quantity: 2, + unitRate: 500, + amount: 1000, + }, + { + chargeType: "HAZARD_SURCHARGE", + description: "Hazard surcharge", + quantity: 2, + unitRate: 250, + amount: 500, + }, + ], + ...overrides, + }; +} + +describe("BillingService.generateInvoice", () => { + let savedLines: unknown[]; + let manager: ReturnType; + let events: ReturnType; + let dataSource: { transaction: jest.Mock; manager: unknown }; + let service: BillingService; + + beforeEach(() => { + savedLines = []; + manager = makeManager(savedLines); + events = makeEvents(); + dataSource = { + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + service = new BillingService( + dataSource as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + }); + + it("creates a PENDING invoice with one line per input line", async () => { + const invoice = await service.generateInvoice(generateInput()); + + expect(invoice.status).toBe(Freight.InvoiceStatus.Pending); + expect(invoice.companyId).toBe("company-1"); + expect(invoice.source).toBe("booking"); + expect(invoice.sourceId).toBe("booking-1"); + expect(invoice.totalAmount).toBe(1500); + expect(invoice.issuedAt).toBeInstanceOf(Date); + expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(savedLines).toHaveLength(2); + }); + + it("sums line amounts when no explicit totalAmount is given", async () => { + const invoice = await service.generateInvoice( + generateInput({ totalAmount: undefined }), + ); + expect(invoice.totalAmount).toBe(1500); + }); + + it("leaves issuedAt null for a DRAFT invoice", async () => { + const invoice = await service.generateInvoice( + generateInput({ status: Freight.InvoiceStatus.Draft }), + ); + expect(invoice.status).toBe(Freight.InvoiceStatus.Draft); + expect(invoice.issuedAt).toBeNull(); + }); + + it("enlists in a caller's transaction when a manager is passed", async () => { + await service.generateInvoice(generateInput(), manager as never); + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect(savedLines).toHaveLength(2); + }); +}); + +describe("BillingService.markInvoiceAsPaid", () => { + it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.objectContaining({ + invoiceId: "inv-1", + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + }), + ); + }); + + it("is a no-op (no event) when the invoice is already paid", async () => { + const paid = { + id: "inv-1", + status: Freight.InvoiceStatus.Paid, + source: "booking", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(paid), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); + +describe("BillingService.settlePayable", () => { + it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.anything(), + ); + }); + + it("is a no-op (returns null) when the source has no open invoice", async () => { + const mg = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + ); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled).toBeNull(); + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39eae6ef5..01b057a76 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,26 +1,542 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { Freight, PaymentReferenceType } from "@edr/types"; +import { DataSource, EntityManager, In } from "typeorm"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; +import { CompaniesService } from "../companies/companies.service"; + +/** Options forwarded to the payment gateway when settling an invoice. */ +export interface PayInvoiceOptions { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; +} + +/** Default invoice payment-term window, in days, used to compute `dueAt`. */ +const DEFAULT_DUE_DAYS = 14; + +/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ +const OPEN_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.Overdue, +]; + +/** A single line to bill on a generated invoice. */ +export interface InvoiceLineInput { + chargeType: string; + description?: string; + /** Units this line bills for; defaults to 1. */ + quantity?: number; + /** Price per unit; defaults to 0. */ + unitRate?: number; + /** Line total; defaults to `quantity * unitRate`. */ + amount?: number; + currency?: string; + metadata?: Record | null; +} + +/** Everything needed to generate an invoice for any source. */ +export interface GenerateInvoiceInput { + /** Originating subsystem; namespaces events (`${source}.invoice.`). */ + source: Freight.InvoiceSource; + /** Identifier of the source record (e.g. booking id). */ + sourceId: string; + /** What the invoice is for (e.g. "prepaid", "credit"). */ + type: string; + companyId: string; + companyProfileId: string; + lines: InvoiceLineInput[]; + currency?: string; + /** Explicit total; defaults to the sum of line amounts. */ + totalAmount?: number; + /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ + dueAt?: Date; + dueInDays?: number; + /** + * Initial status. DRAFT leaves `issuedAt` null; any issued status + * (default PENDING) stamps `issuedAt`. + */ + status?: Freight.InvoiceStatus; +} + +/** Payload broadcast on `${source}.invoice.`. */ +export interface InvoiceEventPayload { + invoiceId: string; + invoiceNumber: string; + source: Freight.InvoiceSource; + sourceId: string; + type: string; + companyId: string; + companyProfileId: string; + totalAmount: number; + currency: string; + status: Freight.InvoiceStatus; + paymentId?: string | null; +} @Injectable() export class BillingService { + private readonly logger = new Logger(BillingService.name); + constructor( - @InjectRepository(Invoice) - private readonly invoicesRepository: Repository, - ) {} + private readonly dataSource: DataSource, + private readonly invoices: InvoiceRepository, + private readonly invoiceLines: InvoiceLineRepository, + private readonly events: EventEmitter2, + @Inject(forwardRef(() => PaymentService)) + private readonly payment: PaymentService, + private readonly companies: CompaniesService, + ) { } + + // ── Reads ────────────────────────────────────────────────────────────────── /** List every invoice (most recent first). */ findAll(): Promise { - return this.invoicesRepository.find({ order: { issuedAt: "DESC" } }); + return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } - /** List invoices for a given booking. */ - findByBooking(bookingId: string): Promise { - return this.invoicesRepository.find({ - where: { bookingId }, + /** Invoice header plus its line items. */ + async findById(id: string): Promise { + const invoice = await this.invoices.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const lines = await this.invoiceLines.findAll({ + where: { invoiceId: id }, + order: { createdAt: "ASC" }, + }); + return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + } + + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── + + /** Resolve the customer's company id from their IAM user id (null if none). */ + async resolveCompanyId(userId: string): Promise { + try { + const { company } = await this.companies.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** Every invoice billed to a company, newest first, with billing relations. */ + findByCompany(companyId: string): Promise { + return this.invoices.findAll({ + where: { companyId }, + relations: { company: true, companyProfile: true }, + order: { createdAt: "DESC" }, + }); + } + + /** Invoices for the signed-in customer; empty when they have no company. */ + async findForUser(userId: string): Promise { + const companyId = await this.resolveCompanyId(userId); + return companyId ? this.findByCompany(companyId) : []; + } + + /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + async findByIdForUser( + id: string, + userId: string, + ): Promise { + const companyId = await this.resolveCompanyId(userId); + const invoice = await this.findById(id); + if (!companyId || invoice.companyId !== companyId) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + /** + * Initiate gateway payment for one of the customer's own invoices. Verifies + * ownership, then charges whichever open invoice the source currently has + * (see {@link payInvoice}). + */ + async payInvoiceForUser( + id: string, + userId: string, + opts: PayInvoiceOptions = {}, + ): Promise { + const invoice = await this.findByIdForUser(id, userId); + return this.payInvoice( + invoice.source as Freight.InvoiceSource, + invoice.sourceId, + opts, + ); + } + + // ── Generation ─────────────────────────────────────────────────────────────── + + /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ + private async nextInvoiceNumber(mg: EntityManager): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `FRT-${ymd}-`; + const [row] = await mg.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; + } + + /** + * Generate an invoice for any source (booking, demurrage, manual, …). + * + * Persists the header plus its lines in one transaction and assigns the next + * sequential `invoice_number`. The total defaults to the sum of line amounts + * unless `totalAmount` is given. Issued invoices (default PENDING) stamp + * `issuedAt`; pass `status: DRAFT` to leave it unissued. + * + * Pass `manager` to enlist in a caller's transaction (e.g. when generating an + * invoice as part of a larger booking flow). + */ + async generateInvoice( + input: GenerateInvoiceInput, + manager?: EntityManager, + ): Promise { + const run = (mg: EntityManager) => this.createInvoice(input, mg); + return manager ? run(manager) : this.dataSource.transaction(run); + } + + private async createInvoice( + input: GenerateInvoiceInput, + mg: EntityManager, + ): Promise { + const currency = input.currency ?? "ETB"; + const status = input.status ?? Freight.InvoiceStatus.Pending; + const issued = status !== Freight.InvoiceStatus.Draft; + + const lines = input.lines.map((l) => { + const quantity = l.quantity ?? 1; + const unitRate = l.unitRate ?? 0; + return { + chargeType: l.chargeType, + description: l.description, + quantity, + unitRate, + amount: l.amount ?? quantity * unitRate, + currency: l.currency ?? currency, + metadata: l.metadata ?? null, + }; + }); + + const totalAmount = + input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + + const dueAt = + input.dueAt ?? + new Date( + Date.now() + + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + ); + + const invoiceNumber = await this.nextInvoiceNumber(mg); + + const invoice = await mg.save( + mg.create(Invoice, { + invoiceNumber, + source: input.source, + sourceId: input.sourceId, + type: input.type, + companyId: input.companyId, + companyProfileId: input.companyProfileId, + totalAmount, + currency, + status, + issuedAt: issued ? new Date() : null, + dueAt, + }), + ); + + const savedLines = await Promise.all( + lines.map((l) => + mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })), + ), + ); + + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`, + ); + + return { ...invoice, lines: savedLines }; + } + + // ── State transitions ──────────────────────────────────────────────────────── + + /** + * Mark an invoice paid and link the gateway payment, then emit + * `${source}.invoice.paid`. Full-payment only — no partial settlement. + * No-op when the invoice is already paid. Pass `manager` to enlist in a + * caller's transaction. + */ + async markInvoiceAsPaid( + invoiceId: string, + paymentId: string | null = null, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Paid, + "paid", + { paymentId: paymentId ?? undefined }, + manager, + ); + } + + /** + * Mark an invoice refunded and emit `${source}.invoice.refunded`. + * No-op when already refunded. + */ + async markInvoiceAsRefunded( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Refunded, + "refunded", + {}, + manager, + ); + } + + /** + * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. + * No-op when already cancelled. + */ + async cancelInvoice( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Cancelled, + "cancelled", + {}, + manager, + ); + } + + /** + * Load the invoice, apply the new status (+ extra columns), then emit + * `${source}.invoice.`. No-op (returns the invoice) when it is already + * in the target status. Throws when the invoice does not exist. + * + * Note: the event fires in-process synchronously. When a `manager` from an + * outer transaction is passed, listeners run before that transaction commits. + */ + private async transition( + invoiceId: string, + status: Freight.InvoiceStatus, + event: string, + extra: { paymentId?: string }, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + if (invoice.status === status) return invoice; + + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + + const updated = { ...invoice, ...extra, status } as Invoice; + this.emitInvoiceEvent(event, updated); + return updated; + } + + /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ + private emitInvoiceEvent(event: string, invoice: Invoice): void { + const payload: InvoiceEventPayload = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source as Freight.InvoiceSource, + sourceId: invoice.sourceId, + type: invoice.type, + companyId: invoice.companyId, + companyProfileId: invoice.companyProfileId, + totalAmount: invoice.totalAmount, + currency: invoice.currency, + status: invoice.status, + paymentId: invoice.paymentId ?? null, + }; + this.events.emit(`${invoice.source}.invoice.${event}`, payload); + } + + // ── Payment reconciliation (by source) ─────────────────────────────────────── + + /** + * The invoice a gateway payment should settle for a source record, or null if + * none. This is the billing document of record for "what is owed" — callers + * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than + * recomputing from the source's own total, so discounts/penalties/adjustments + * carried on the invoice are honored. + * + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching open (unpaid, + * non-cancelled) invoice. + */ + findPayable( + source: Freight.InvoiceSource, + sourceId: string, + type?: string, + ): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); } + + /** + * Settle a source's currently-open invoice as paid and link the gateway + * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then + * delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial + * settlement. No-op (returns null) when the source has no open invoice. + * + * Type-blind by design: settles whichever invoice is due; any per-type reaction + * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`. + * Pass the caller's transaction `manager` to enlist in its DB transaction. + * + * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` + * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper + * for callers that settle by source rather than by gateway intent id. + */ + async settlePayable( + source: Freight.InvoiceSource, + sourceId: string, + paymentId: string | null, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId, mg); + } + + /** + * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. + * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. + * No-op (returns null) when the source has no paid invoice. + * + * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) + * to enlist in its DB transaction. + */ + async refundPayable( + source: Freight.InvoiceSource, + sourceId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsRefunded(invoice.id, mg); + } + + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── + + /** + * Charge a source's open invoice through the payment gateway. Billing is the + * single place that turns "what is owed" (the invoice) into a payment intent — + * the domain never talks to the payment service directly. Resolves the open + * invoice, opens an intent for `invoice.totalAmount`, records the intent id on + * the invoice (the settlement correlation key), and returns the client action. + * + * When the provider settles synchronously, the invoice is settled inline here — + * after the intent id is stored — so the `payment.succeeded` correlation can + * never fire before the link exists. Throws when the source has no open invoice. + */ + async payInvoice( + source: Freight.InvoiceSource, + sourceId: string, + opts: { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + } = {}, + ): Promise { + const invoice = await this.findPayable(source, sourceId); + if (!invoice) { + throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + } + + const result = await this.payment.initiate({ + referenceId: sourceId, + source: invoice.source, + // Gateway reference type derives from the invoice source by convention + // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and + // the domain never supplies it. New sources add their uppercased value to + // the PaymentReferenceType enum. + referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + orderRef: invoice.invoiceNumber, + amountMinor: Math.round(Number(invoice.totalAmount)), + currency: invoice.currency, + reason: `Payment for invoice ${invoice.invoiceNumber}`, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + payerAccount: opts.payerAccount, + returnUrl: opts.returnUrl, + failureUrl: opts.failureUrl, + }); + + // Link the intent to the invoice BEFORE any settlement can correlate against it. + await this.dataSource + .getRepository(Invoice) + .update({ id: invoice.id }, { paymentId: result.intentId }); + + if (result.immediateSuccess) { + await this.settleByPaymentId( + result.intentId, + result.providerTxnId, + result.paidAt, + ); + } + + return result.response; + } + + /** + * Settle the open invoice linked to a gateway intent id, if any. Called by the + * payment service when an intent succeeds: finds the invoice linked by + * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain + * to advance on. Idempotent — no-op when no open invoice is linked (already + * settled, or settled inline by {@link payInvoice}). + */ + async settleByPaymentId( + paymentId: string, + _providerTxnId?: string, + _paidAt?: Date, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { paymentId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId); + } } diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts new file mode 100644 index 000000000..c29160ab7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +/** Gateway options for paying an invoice from the customer portal. */ +export class PayInvoiceDto { + @ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." }) + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: "web" | "mobile"; + + @ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." }) + @IsOptional() + @IsString() + payerAccount?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on success." }) + @IsOptional() + @IsString() + returnUrl?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on failure." }) + @IsOptional() + @IsString() + failureUrl?: string; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts new file mode 100644 index 000000000..a042dedb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "./invoice.entity"; + +@Entity({ schema: "freight", name: "invoice_lines" }) +export class InvoiceLine extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid", nullable: false }) + invoiceId!: string; + + @ManyToOne(() => Invoice, { onDelete: "CASCADE" }) + @JoinColumn({ name: "invoice_id" }) + invoice!: Invoice; + + @Column({ name: "charge_type", type: "varchar", nullable: false }) + chargeType!: string; + + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string; + + /** Units this line bills for (e.g. container count, wagon count, tons). */ + @Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 }) + quantity!: number; + + /** Price per unit; `amount` is normally `quantity * unitRate`. */ + @Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ + name: "amount", + type: "numeric", + precision: 14, + scale: 2, + nullable: false, + }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ name: "metadata", type: "jsonb", nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index e2a6f7cc2..61bc9c16b 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,17 +1,35 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { PaymentEntity } from "../../payment/entities/payment.entity"; +import { Company } from "../../companies/entities/company.entity"; +import { CompanyProfile } from "../../companies/entities/company-profile.entity"; -@Entity({schema:"freight", name: "invoices" }) +@Entity({ schema: "freight", name: "invoices" }) +@Index(["companyId"]) +@Index(["companyProfileId"]) export class Invoice extends BaseEntity { - @Column({ name: "booking_id", type: "uuid" }) - bookingId!: string; - @Column({ name: "invoice_number", type: "varchar", length: 64, unique: true }) invoiceNumber!: string; - @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) - amount!: number; + /** The customer (company) this invoice is billed to. */ + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** The specific company profile (importer/exporter/forwarder/...) billed. */ + @Column({ name: "company_profile_id", type: "uuid" }) + companyProfileId!: string; + + @ManyToOne(() => CompanyProfile) + @JoinColumn({ name: "company_profile_id" }) + companyProfile?: CompanyProfile; + + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) + totalAmount!: number; @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -19,13 +37,38 @@ export class Invoice extends BaseEntity { @Column({ name: "status", type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + enum: Freight.InvoiceStatus, + default: Freight.InvoiceStatus.Draft, }) - status!: Freight.PaymentStatus; + status!: Freight.InvoiceStatus; - @Column({ name: "issued_at", type: "timestamptz" }) - issuedAt!: Date; + /** The source of the payment (e.g. booking, customer, etc.). */ + @Column({ name: "source", type: "varchar", length: 255, nullable: false }) + source!: string; + + /** The ID of the source (e.g. booking ID, customer ID, etc.). */ + @Column({ name: "source_id", type: "varchar", length: 255, nullable: false }) + sourceId!: string; + + /** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */ + @Column({ + type: "varchar", + length: 255, + nullable: false, + }) + type!: string; + + /** Set when the invoice is actually issued (DRAFT invoices leave this null). */ + @Column({ name: "issued_at", type: "timestamptz", nullable: true }) + issuedAt?: Date | null; + + /** The ID of the payment that generated this invoice. */ + @Column({ name: "payment_id", type: "uuid", nullable: true }) + paymentId?: string | null; + + @ManyToOne(() => PaymentEntity) + @JoinColumn({ name: "payment_id" }) + payment?: PaymentEntity; @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; diff --git a/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts new file mode 100644 index 000000000..6a5482543 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { InvoiceLine } from "./entities/invoice-line.entity"; + +@Injectable() +export class InvoiceLineRepository extends BaseRepository { + constructor( + @InjectRepository(InvoiceLine) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts new file mode 100644 index 000000000..cc2e89df7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Invoice } from "./entities/invoice.entity"; + +@Injectable() +export class InvoiceRepository extends BaseRepository { + constructor( + @InjectRepository(Invoice) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts new file mode 100644 index 000000000..5a007c320 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; + +import { + type AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { BillingService } from "./billing.service"; +import { PayInvoiceDto } from "./dto/pay-invoice.dto"; + +/** + * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, + * org-wide), every route here is force-scoped to the signed-in customer's + * company — they only ever see and pay their own invoices. + */ +@ApiTags("billing") +@ApiBearerAuth() +@Controller("billing") +export class PortalBillingController { + constructor(private readonly billingService: BillingService) {} + + @Get("my-invoices") + @ApiOperation({ summary: "List the signed-in customer's invoices" }) + findMine(@CurrentUser() user: AuthUserPayload) { + return this.billingService.findForUser(resolveAuthUserId(user)); + } + + @Get("my-invoices/:id") + @ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" }) + findMineById( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); + } + + @Post("my-invoices/:id/pay") + @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) + pay( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: PayInvoiceDto, + ) { + return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), { + method: dto.method, + platform: dto.platform ?? "web", + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts new file mode 100644 index 000000000..47338f196 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -0,0 +1,201 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; +import { DataSource } from 'typeorm'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, + InvoiceLineInput, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { PriceLineItemDto } from './dto/generate-price-response.dto'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; + +/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ +interface StoredPricingBreakdown { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; +} + +/** Round to 2 decimals, avoiding binary float drift. */ +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Owns the booking ⇄ invoice mapping — the one place that knows how a booking + * turns into invoices, which type to use, and how it advances when paid. Bookings + * are the billable business entity, so they generate their own invoices directly + * via {@link BillingService} (billing stays source-agnostic). All booking-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class BookingInvoiceService { + private readonly logger = new Logger(BookingInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly bookingsRepository: BookingsRepository, + private readonly dataSource: DataSource, + @Inject(forwardRef(() => FirstMileService)) + private readonly firstMile: FirstMileService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatch: BookingBatchService, + ) { } + + /** + * Ensure the booking has its invoice, generating one from the snapshotted + * pricing breakdown if absent. Called when a booking reaches a billable state. + * Idempotent — returns the existing open invoice instead of a duplicate. + * Returns `null` (and logs) when the booking is not billable: no company to + * bill (e.g. government bookings whose `companyId` is null, which the invoices + * FK requires), or no priced amount. + */ + async ensureInvoiceForBooking(booking: Booking): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + booking.id, + Freight.InvoiceType.Prepaid, + ); + if (existing) return existing; + + if (!booking.companyId) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + ); + return null; + } + + const input = this.buildInput(booking); + if (!input) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + return null; + } + + return this.billing.generateInvoice(input); + } + + /** + * React to a booking invoice being paid — the settlement branch point. Per-type + * reactions live here (not in the payment process): each invoice type advances + * the booking its own way. Only PREPAID exists today. + */ + @OnEvent('booking.invoice.paid') + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + switch (payload.type) { + case Freight.InvoiceType.Prepaid: + await this.advanceBookingOnPayment(payload.sourceId); + break; + default: + this.logger.warn( + `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, + ); + } + } + + /** + * Advance a booking once its prepaid invoice settles — the domain side-effect + * of payment, relocated out of the payment service: the booking becomes PAID + * and is allocated into its batch. Idempotent — no-op when already PAID. + * + * General contracts are a separate aggregate now: their CONTRACT_ACTIVE + * lifecycle and ordering window live in the contracts module, advanced by the + * contract transition/clearance services — not by booking payment. Every + * booking that settles here is a ONE_TIME shipment, so there is no contract + * branch (legacy GENERAL_CONTRACT booking creation now 410s). + */ + private async advanceBookingOnPayment(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`); + return; + } + if (booking.paymentStatus === 'PAID') return; + + await this.dataSource.transaction(async (mg) => { + await mg.update( + Booking, + { id: bookingId }, + { paymentStatus: 'PAID', status: 'PAID' }, + ); + await this.firstMile.acceptBooking(bookingId); + }); + + try { + await this.bookingBatch.ensurePaidBookingAllocated(bookingId); + } catch (err) { + this.logger.error( + `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Map a booking's pricing snapshot into a generic invoice request. */ + private buildInput(booking: Booking): GenerateInvoiceInput | null { + const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB'; + + const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ + chargeType: l.code, + description: l.description, + quantity: l.quantity, + unitRate: l.unitAmount, + amount: l.amount, + currency: l.currency ?? currency, + metadata: l.unit ? { unit: l.unit } : null, + })); + + // Fall back to a single freight line when no breakdown was snapshotted. + if (lines.length === 0) { + const amount = Number(booking.totalAmount); + if (!Number.isFinite(amount) || amount <= 0) return null; + lines.push({ + chargeType: 'FREIGHT', + description: 'Rail freight', + quantity: 1, + unitRate: amount, + amount, + currency, + }); + } + + const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0)); + let totalAmount = subtotal; + + // Honor a staff price override: bill the adjusted total, recording the delta + // as an ADJUSTMENT line so the lines still sum to the invoice total. + const adjusted = booking.adjustedTotalAmount; + if (adjusted != null && Number.isFinite(Number(adjusted))) { + const delta = round2(Number(adjusted) - subtotal); + if (delta !== 0) { + lines.push({ + chargeType: 'ADJUSTMENT', + description: 'Staff price adjustment', + quantity: 1, + unitRate: delta, + amount: delta, + currency, + }); + } + totalAmount = round2(Number(adjusted)); + } + + return { + source: Freight.InvoiceSource.Booking, + sourceId: booking.id, + type: Freight.InvoiceType.Prepaid, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency, + lines, + totalAmount, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts new file mode 100644 index 000000000..ae01ebc36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts @@ -0,0 +1,180 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Query, + Res, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { Public } from "@edr/api-common"; +import { Freight } from "@edr/types"; + +import { BillingService } from "../billing/billing.service"; +import { + InitiatePaymentDto, + InitiateResponseDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "../payment/payments.dto"; + +/** + * Booking-payment entrypoints. This is the ONE place that knows a payment is for a + * booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands + * off to billing, which resolves the invoice/amount and drives the gateway. Billing + * and payment stay source-agnostic; the booking knowledge lives here, in the domain. + * Routes are unchanged (`/payments/*`) so the portal is unaffected. + */ +@ApiTags("Payment") +@Controller("payments") +export class BookingPaymentController { + constructor(private readonly billing: BillingService) { } + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment for a freight booking", + description: "Charges the booking's open invoice through the payment gateway.", + }) + @ApiOkResponse({ type: InitiateResponseDto }) + initiate(@Body() dto: InitiatePaymentDto): Promise { + return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + method: dto.method, + platform: dto.platform, + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } + + @Get("checkout") + @Public() + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") + async checkout( + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", + @Res() res: Response, + ) { + if (!bookingId) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + } + if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing or invalid query parameter: method")); + } + + try { + const result = await this.billing.payInvoice( + Freight.InvoiceSource.Booking, + bookingId, + { method, platform }, + ); + const url = + result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined; + + if (url) { + return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url)); + } + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "An unexpected error occurred"; + return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message)); + } + } + + private buildRedirectHtml(url: string): string { + const escaped = url.replace(/\"/g, """); + return ` + + + + + Redirecting to payment… + + + +
+
+

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..8be7325ba 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'; @@ -33,7 +37,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @Module({ @@ -48,7 +51,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingReviewNote, BookingContractSignature, ]), - PaymentModule, + BillingModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, @@ -63,7 +67,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, PayController, BookingPaymentController], providers: [ BookingsService, BookingsRepository, @@ -72,6 +76,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingPricingService, BookingTransitionService, BookingContractService, + BookingInvoiceService, BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, @@ -79,6 +84,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService], + exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], }) export class BookingsModule {} 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..50d15cc64 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'; @@ -307,10 +307,23 @@ export class BookingsService { let companyId: string | null | undefined = dto.companyId; if (isGovernment) { - if (!dto.governmentInstitution?.trim()) { - throw new BadRequestException('governmentInstitution is required for government bookings'); + // Government bookings bill to a real seeded government company + an + // explicitly-chosen importer/exporter profile (no more null company + + // free-text institution). + if (!dto.companyId) { + throw new BadRequestException('A government company is required for government bookings'); } - companyId = dto.companyId ?? null; + const govCompany = await this.companiesService.findCompanyById(dto.companyId); + if (govCompany.kind !== CompanyKind.Government) { + throw new BadRequestException('Selected company is not a government entity'); + } + if (govCompany.status !== CompanyStatus.Active) { + throw new BadRequestException('Selected government company is not active'); + } + if (!dto.companyProfileId) { + throw new BadRequestException('A government company profile is required for government bookings'); + } + companyId = govCompany.id; } else if (!companyId) { if (!userId) { throw new BadRequestException( @@ -383,7 +396,16 @@ export class BookingsService { // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. let companyProfileId: string | null = null; - if (!isGovernment && companyId) { + if (dto.companyProfileId && companyId) { + // Explicit profile pin (government booking, or staff booking on behalf): + // must belong to the chosen company and be active. + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else if (companyId) { let fallbackType: ProfileType | null = null; if (userId) { try { @@ -413,6 +435,16 @@ export class BookingsService { } } + // Every booking must link to a company and a company profile. + if (!companyId) { + throw new BadRequestException('A company is required to create a booking'); + } + if (!companyProfileId) { + throw new BadRequestException( + 'A company profile is required to create a booking — none could be resolved for this company', + ); + } + const needsConsolidation = dto.freightType === 'CONTAINER' ? await this.needsConsolidation(containers) @@ -443,10 +475,10 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, - companyId: companyId ?? null, + companyId, companyProfileId, isGovernment, - governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, + governmentInstitution: dto.governmentInstitution?.trim() || null, trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 677ef03fd..9380faba5 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -14,7 +14,6 @@ import { Max, MaxLength, Min, - MinLength, Validate, ValidateIf, ValidateNested, @@ -104,19 +103,31 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isGovernment?: boolean; - @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) - @ValidateIf((o) => o.isGovernment === true) + /** @deprecated Government bookings now bill to a real government company. */ + @ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' }) + @IsOptional() @IsString() - @MinLength(2) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) governmentInstitution?: string; - @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) - @ValidateIf((o) => o.isGovernment !== true) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.', + }) @IsOptional() @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index ac7fe636a..b9573d680 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 @@ -105,8 +105,10 @@ export class Booking extends BaseEntity { // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; - @Column({ name: 'company_id', type: 'uuid', nullable: true }) - companyId?: string | null; + // Every booking is billed to a company — government bookings bill to a seeded + // government company (companies.kind = 'government'). Enforced NOT NULL. + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) @@ -116,11 +118,12 @@ export class Booking extends BaseEntity { * The operational profile (importer/exporter/forwarder) this booking belongs * to. Stamped at creation from the booking's trade direction (IMPORT→importer, * EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder. - * Customer portal lists and dashboard KPIs are scoped by this. Nullable for - * legacy/government/staff-created bookings. + * Customer portal lists and dashboard KPIs are scoped by this. Required: + * commercial bookings resolve it from trade direction / active mode; + * government bookings carry the explicitly-picked government profile. */ - @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) - companyProfileId?: string | null; + @Column({ name: 'company_profile_id', type: 'uuid' }) + companyProfileId!: string; @ManyToOne(() => CompanyProfile, { nullable: true }) @JoinColumn({ name: 'company_profile_id' }) 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/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 17ed1c101..e810d23cc 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 @@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; 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..45c2658db 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 @@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; @@ -36,7 +36,7 @@ export class FirstMileService { private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} /** @@ -251,16 +251,19 @@ export class FirstMileService { const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.firstMilePickupAddress, - destinationYard: booking?.originYard?.label, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + + (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } 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..ad4b789f4 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 @@ -34,6 +34,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; 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..77a8a2fea 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 @@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; @@ -32,12 +32,11 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( - private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} async acceptBooking(bookingReference: string): Promise { @@ -185,16 +184,19 @@ export class LastMileService { }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.destinationYard?.label, - destinationYard: booking?.lastMileDeliveryAddress, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a last-mile delivery. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') + + (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 2bb81a331..5c4a4f7f7 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat import { PaymentRefundEntity } from "./payment-refund.entity"; -type PaymentType = "booking" +/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ +type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity { @Column({ type: 'varchar', length: 255, name: "ref_id" }) refId!: string - @Column({ type: "enum", enum: ["booking"] }) + @Column({ type: "varchar", length: 50 }) type!: PaymentType; + @Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" }) + referenceType?: string; + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) method!: PaymentMethod diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index f1f34c3b1..b1b269665 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -1,13 +1,13 @@ import { - Body, Controller, Get, HttpStatus, Param, ParseUUIDPipe, - Post, Query, Res, + Body, + Post, } from "@nestjs/common"; import { ApiTags, @@ -20,14 +20,7 @@ import { Response } from "express"; import { Public } from "@edr/api-common"; import { BookingView, FreightAdmin } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { - InitiatePaymentDto, - InitiateResponseDto, - IntentStatusDto, - PaymentMethodTypeEnum, - PaymentPlatformDto, - RefundDto, -} from "./payments.dto"; +import { IntentStatusDto, RefundDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,16 +66,6 @@ export class PaymentController { }); } - @Post("initiate") - @ApiOperation({ - summary: "Initiate payment for a freight booking", - description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, - }) - @ApiOkResponse({ type: InitiateResponseDto }) - initiatePayment(@Body() dto: InitiatePaymentDto) { - return this.paymentService.initiatePayment(dto); - } - @Get("intents/:bookingId") @ApiOperation({ summary: "Get payment intent status for a booking" }) @ApiOkResponse({ type: IntentStatusDto }) @@ -97,54 +80,6 @@ export class PaymentController { return this.paymentService.refund(dto); } - @Get("checkout") - @Public() - @ApiOperation({ - summary: "Browser checkout redirect", - description: - "Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", - }) - @ApiQuery({ name: "bookingId", required: true }) - @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) - @ApiProduces("text/html") - async checkout( - @Query("bookingId") bookingId: string, - @Query("method") method: PaymentMethodTypeEnum, - @Query("platform") platform: PaymentPlatformDto = "web", - @Res() res: Response, - ) { - if (!bookingId) { - return res - .status(HttpStatus.BAD_REQUEST) - .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); - } - if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { - return res - .status(HttpStatus.BAD_REQUEST) - .type("html") - .send(this.buildErrorHtml("Missing or invalid query parameter: method")); - } - - try { - const result = await this.paymentService.initiatePayment({ bookingId, method, platform }); - const url = - result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined; - - if (url) { - return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url)); - } - return res - .status(HttpStatus.OK) - .type("html") - .send(this.buildStatusHtml(result.status, result.intentId)); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "An unexpected error occurred"; - return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message)); - } - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) @@ -153,76 +88,4 @@ export class PaymentController { const html = await this.paymentService.genReceiptHtml(orderId); return res.status(HttpStatus.OK).type("html").send(html); } - - private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, """); - return ` - - - - - Redirecting to payment… - - - -
-
-

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..696d027b5 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,80 +310,59 @@ export class PaymentService { if (!local) throw new NotFoundException("PaymentIntent not found"); return this.formatIntentStatus(local); } + if (!local) throw new NotFoundException("PaymentIntent not found"); - const booking = await this.datasource - .getRepository(Booking) - .findOneBy({ id: bookingId }); + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - if (!booking) throw new NotFoundException("Booking not found"); - - const intent = await this.syncIntentProjection(bookingId, booking, snapshot); - - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: booking.id, + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); } - const refreshed = await this.paymentRepo.findOneBy({ id: intent.id }); - return this.formatIntentStatus(refreshed ?? intent); + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); } - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async finalizePaymentSuccess(input: { - intentId: string; - bookingId: string; - providerTxnId?: string; - paidAt?: Date; - }): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === "success") return { alreadyFinalized: true }; - const paidAt = input.paidAt ?? new Date(); + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, + ); - // Every booking is a real shipment now (contracts are a separate aggregate), - // so payment always settles the booking to PAID and enters allocation. - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - ); - await mg.update( - Booking, - { id: input.bookingId }, - { paymentStatus: "PAID", status: "PAID" }, - ); - await this.firstMileService.acceptBooking(input.bookingId); - }); - - try { - await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); - } catch (err) { - this.logger.error( - `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, - ); + if (opts.notify !== false) { + await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); } return { alreadyFinalized: false }; } + async markPaymentFailed(input: { intentId: string; @@ -335,6 +377,29 @@ export class PaymentService { { id: intent.id }, { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + // NOTE: refunding still mutates the booking directly — left intact pending + // the refund redesign. TODO: route refunds through billing.refundPayable + + // a `${source}.invoice.refunded` reaction, like settlement. + await this.datasource.transaction(async (mg) => { + await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); + await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); + }); + + return { refunded: true, bookingId: dto.bookingId }; } async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { @@ -363,7 +428,7 @@ export class PaymentService { } findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id, type: "booking" }); + return this.paymentRepo.findOneBy({ refId: id }); } formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { @@ -398,24 +463,37 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: event.referenceId, + console.log(`Processing payment succeeded event for intent: }`,intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, }); + console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + + // When the intent references a booking, flip the booking itself paid. + // refId holds the booking id (the domain reference the intent opened with). + if (intent.referenceType === PaymentReferenceType.BOOKING) { + await this.datasource.manager.update( + Booking, + { id: intent.refId }, + { status: "PAID", paymentStatus: "PAID" }, + ); + } + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; } if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } await this.markPaymentFailed({ intentId: intent.id, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 5c9cb6119..c5ab46987 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -13,7 +13,7 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; /** How long a selected commercial customer has to pay before their slot expires. */ // export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) +export const PAYMENT_WINDOW_MS = 180 * 60 * 1000; // 5 minutes (test mode) /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index ca16bcaa5..c1faeed22 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -32,6 +32,9 @@ export interface ImportTrainItemRow { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; containerNumber: string | null; cargoType: string | null; weight: number | null; @@ -59,6 +62,9 @@ export interface ExportTrainItemRow { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; itemType: 'CONTAINER' | 'CARGO'; itemId: string | null; inventoryId: string | null; @@ -265,6 +271,9 @@ export class SchedulingReadFacade { b.reference AS "bookingReference", b.company_id AS "customerId", company.name AS "customerName", + w.wagon_number AS "wagonNumber", + tsw.sequence_no AS "sequenceNo", + wba.allocated_weight_tons AS "allocatedWeightTons", (SELECT c.container_number FROM freight.containers c WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", @@ -279,11 +288,24 @@ export class SchedulingReadFacade { FROM freight.train_schedule_bookings tsb JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id + AND wba.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM freight.train_set_wagons tsw_match + WHERE tsw_match.id = wba.train_set_wagon_id + AND tsw_match.train_set_id = ts.train_set_id + AND tsw_match.deleted_at IS NULL + ) + LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + AND tsw.train_set_id = ts.train_set_id + AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - ORDER BY b.reference ASC NULLS LAST`, + ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`, [scheduleId], ); return rows; @@ -401,6 +423,9 @@ export class SchedulingReadFacade { b.reference, b.company_id, company.name AS customer_name, + w.wagon_number, + tsw.sequence_no, + wba.allocated_weight_tons, COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type, b.cargo_total_weight_vgm AS booking_weight, oy.code AS origin, @@ -412,6 +437,19 @@ export class SchedulingReadFacade { FROM freight.train_schedule_bookings tsb JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id + AND wba.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM freight.train_set_wagons tsw_match + WHERE tsw_match.id = wba.train_set_wagon_id + AND tsw_match.train_set_id = ts.train_set_id + AND tsw_match.deleted_at IS NULL + ) + LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + AND tsw.train_set_id = ts.train_set_id + AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -423,6 +461,9 @@ export class SchedulingReadFacade { a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", + a.wagon_number AS "wagonNumber", + a.sequence_no AS "sequenceNo", + a.allocated_weight_tons AS "allocatedWeightTons", 'CONTAINER' AS "itemType", c.id AS "itemId", a.inventory_id AS "inventoryId", @@ -441,6 +482,9 @@ export class SchedulingReadFacade { a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", + a.wagon_number AS "wagonNumber", + a.sequence_no AS "sequenceNo", + a.allocated_weight_tons AS "allocatedWeightTons", 'CARGO' AS "itemType", cg.id AS "itemId", a.inventory_id AS "inventoryId", @@ -460,6 +504,9 @@ export class SchedulingReadFacade { a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", + a.wagon_number AS "wagonNumber", + a.sequence_no AS "sequenceNo", + a.allocated_weight_tons AS "allocatedWeightTons", CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", a.inventory_id AS "itemId", a.inventory_id AS "inventoryId", @@ -474,7 +521,7 @@ export class SchedulingReadFacade { FROM assigned a WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) - ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`, + ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`, [scheduleId], ); return rows; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 204d0f0d4..68f536b33 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; @@ -273,6 +273,25 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get(':id/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF' }) + async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Post('bookings/:bookingId/approve-delivery') + @ApiOperation({ summary: "Approve delivery using the current customer's saved signature" }) + approveDeliveryForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Request() req: { user?: { id?: string; sub?: string } }, + ) { + return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 2bfddbe37..618553df3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) => const isLoadableWagonStatus = (status: string | null | undefined) => LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); +const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; + export interface InventoryInquiryResult { id: string; inventoryId: string | null; @@ -297,6 +300,9 @@ export interface ImportUnloadedRow { pickupOption: string; lastMileRequested: boolean; currentStatus: string; + releaseDate: string | null; + releaseOrderReference: string | null; + deliveredAt: string | null; } @Injectable() @@ -316,6 +322,7 @@ export class WarehouseInventoryService { private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, private readonly notifications: NotificationsService, + private readonly signatures: SignaturesService, ) {} /** @@ -1020,6 +1027,9 @@ export class WarehouseInventoryService { THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", inv.status AS "currentStatus", + inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", + inv.delivered_at AS "deliveredAt", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv @@ -1049,7 +1059,16 @@ export class WarehouseInventoryService { * states), with the columns the inspection screen needs. Read-only. */ importUnloadedQueue(): Promise { - return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']); + return this.importQueueByStatuses([ + 'UNLOADED', + 'DESTINATION_INSPECTION', + 'UNDER_INSPECTION', + 'ARRIVED_AT_WAREHOUSE', + 'STORED', + 'READY_FOR_PICKUP', + 'DISPATCHED', + 'DELIVERED', + ]); } /** @@ -2020,6 +2039,154 @@ export class WarehouseInventoryService { } /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ + async approveDeliveryForBooking( + bookingId: string, + userId?: string, + ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> { + if (!userId) { + throw new BadRequestException('Authentication is required to approve delivery'); + } + + const signature = await this.signatures.getForUser(userId); + if (!signature?.signatureImageUrl) { + throw new BadRequestException('Please save your signature before approving delivery'); + } + + const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> = + await this.dataSource.query( + `SELECT inv.id, + inv.warehouse_id AS "warehouseId", + inv.notes + FROM freight.warehouse_inventory inv + JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.inspection_status = 'PASSED' + ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC + LIMIT 1`, + [bookingId], + ); + + if (!item) { + throw new BadRequestException('Delivery can be approved after warehouse inspection has passed'); + } + + const approvedAt = new Date(); + const approval = { + approvedAt: approvedAt.toISOString(), + signerDisplayName: signature.signerDisplayName, + signatureImageUrl: signature.signatureImageUrl, + userId, + }; + const existingNotes = this.stripCustomerDeliveryApproval(item.notes); + const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + notes: this.appendNote(existingNotes, approvalNote), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RELEASED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: `Customer approved delivery as ${signature.signerDisplayName}`, + performedBy: signature.signerDisplayName, + }, + manager, + ); + }); + + return { + bookingId, + inventoryId: item.id, + approvedAt: approval.approvedAt, + signerDisplayName: signature.signerDisplayName, + }; + } + + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const [row] = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + inv.quantity, + inv.weight, + inv.status, + inv.notes, + inv.inspection_status AS "inspectionStatus", + inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", + COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + company.name AS "customerName", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.name AS "zoneName", + zone.code AS "zoneCode", + ts.train_number AS "trainSchedule" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [id], + ); + if (!row) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + if (row.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Handover document is available after inspection has passed'); + } + + const bookingReference = row.bookingReference || row.bookingId || 'N/A'; + const html = this.buildHandoverDocumentHtml({ + reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`, + handedOverAt: new Date(row.handoverDate ?? Date.now()), + bookingReference, + bookingStatus: row.bookingStatus ?? null, + customerName: row.customerName ?? null, + freightType: row.freightType ?? null, + tradeDirection: row.tradeDirection ?? null, + containerNumber: row.containerNumber ?? null, + cargoDescription: row.cargoDescription ?? null, + quantity: Number(row.quantity ?? 0), + weight: Number(row.weight ?? 0), + warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null, + yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null, + zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null, + inventoryStatus: row.status ?? null, + inspectionStatus: row.inspectionStatus ?? null, + releaseOrderReference: row.releaseOrderReference ?? null, + releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, + trainSchedule: row.trainSchedule ?? null, + customerApproval: this.extractCustomerDeliveryApproval(row.notes), + }); + + return { + filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + }; + } + async deliver(id: string, dto: DeliverInventoryDto): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'DELIVERED'); @@ -2619,6 +2786,183 @@ export class WarehouseInventoryService { `; } + private buildHandoverDocumentHtml(data: { + reference: string; + handedOverAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + freightType: string | null; + tradeDirection: string | null; + containerNumber: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + inspectionStatus: string | null; + releaseOrderReference: string | null; + releaseDate: Date | null; + trainSchedule: string | null; + customerApproval: { + approvedAt: string; + signerDisplayName: string; + signatureImageUrl: string; + } | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const fmt = (date: Date | string | null) => { + if (!date) return '-'; + const parsed = date instanceof Date ? date : new Date(date); + if (Number.isNaN(parsed.getTime())) return '-'; + return parsed.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + const rows = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Booking Status', data.bookingStatus], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Train Schedule', data.trainSchedule], + ['Container Number', data.containerNumber], + ['Cargo / Goods Description', data.cargoDescription], + ['Quantity', data.quantity], + ['Declared Weight', `${data.weight.toLocaleString()} kg`], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory Status', data.inventoryStatus], + ['Inspection Status', data.inspectionStatus], + ['Release Order', data.releaseOrderReference], + ['Release Date', fmt(data.releaseDate)], + ]; + const approval = data.customerApproval; + + return ` + + + + Import Goods Handover Document + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Import Goods Handover Document

+
EDR to customer warehouse handover
+
+
+ Document No. + ${esc(data.reference)} + Handover: ${esc(fmt(data.handedOverAt))} +
+
+
+
+ This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed. +
+
Handover Particulars
+
+ + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
Goods List
+ + + + + + +
1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}
Container${esc(data.containerNumber)}
Weight${esc(`${data.weight.toLocaleString()} kg`)}
+
Handover Clause
+
+ The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference, + inspection status, and release records before final physical handover. +
+
+
Officer in charge name / signature / date
+
EDR
Warehouse
Handover
+
+ ${approval?.signatureImageUrl ? `` : ''} +
${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}
+
${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}
+
+
+ +`; + } + + private extractCustomerDeliveryApproval(notes?: string | null): { + approvedAt: string; + signerDisplayName: string; + signatureImageUrl: string; + } | null { + if (!notes) return null; + const line = notes + .split(/\r?\n/) + .find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX)); + if (!line) return null; + try { + const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length)); + if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null; + return { + approvedAt: String(parsed.approvedAt), + signerDisplayName: String(parsed.signerDisplayName), + signatureImageUrl: String(parsed.signatureImageUrl), + }; + } catch { + return null; + } + } + + private stripCustomerDeliveryApproval(notes?: string | null): string | null { + if (!notes?.trim()) return null; + const lines = notes + .split(/\r?\n/) + .filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX)); + return lines.join('\n').trim() || null; + } + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { throw new BadRequestException(`Invalid transition ${from} → ${to}`); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 1671b63e8..d880a3554 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts b/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts new file mode 100644 index 000000000..21750a755 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts @@ -0,0 +1,85 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.TYPEORM_LOGGING = 'false'; + +import { AppModule } from '../app.module'; +import { deriveTradeDirection } from '../common/derive-trade-direction.util'; +import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + + try { + const dataSource = app.get(DataSource); + const inventory = app.get(WarehouseInventoryService); + + const schedules: { + id: string; + trainNumber: string | null; + originCountry: string | null; + destinationCountry: string | null; + }[] = await dataSource.query( + `SELECT ts.id, + ts.train_number AS "trainNumber", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.status = 'ARRIVED' + AND ts.deleted_at IS NULL + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST, + ts.created_at DESC`, + ); + + const importSchedules = schedules.filter( + (schedule) => + deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ) === 'IMPORT', + ); + + if (importSchedules.length === 0) { + console.log('No ARRIVED import trains found.'); + return; + } + + for (const schedule of importSchedules) { + const result = await inventory.autoUnloadArrivedBookings( + schedule.id, + 'Demo Auto Unload', + ); + console.log( + `${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`, + ); + for (const item of result.results) { + console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`); + } + } + + const queueRows = await inventory.importUnloadedQueue(); + console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`); + const byStatus = queueRows.reduce>((acc, row) => { + acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1; + return acc; + }, {}); + for (const [status, count] of Object.entries(byStatus)) { + console.log(` ${status}: ${count}`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 732de6262..ff4a34493 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -1,16 +1,30 @@ import 'reflect-metadata'; import { config } from 'dotenv'; import { resolve } from 'path'; +import { WagonStatus } from '@edr/types'; config({ path: resolve(__dirname, '../../.env') }); import { AppDataSource } from '../data-source'; +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01'; +const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const; function addHours(date: Date, hours: number): Date { return new Date(date.getTime() + hours * 60 * 60 * 1000); @@ -25,18 +39,34 @@ async function main() { const locomotiveRepo = manager.getRepository(Locomotive); const trainSetRepo = manager.getRepository(TrainSet); const scheduleRepo = manager.getRepository(TrainSchedule); + const wagonTypeRepo = manager.getRepository(WagonType); + const wagonRepo = manager.getRepository(Wagon); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const serviceTypeRepo = manager.getRepository(ServiceType); + const containerTypeRepo = manager.getRepository(ContainerType); + const companyRepo = manager.getRepository(Company); + const bookingRepo = manager.getRepository(Booking); + const bookingContainerRepo = manager.getRepository(BookingContainer); + const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); + const importOperationRepo = manager.getRepository(ImportDjiboutiOperation); const negad = (await yardRepo.findOne({ where: { code: 'NEGAD' } })) ?? (await yardRepo.save( yardRepo.create({ code: 'NEGAD', - label: 'Negad', + label: 'Negad / Nagad', country: 'Djibouti', isActive: true, displayOrder: 5, }), )); + if (negad.label !== 'Negad / Nagad') { + negad.label = 'Negad / Nagad'; + await yardRepo.save(negad); + } const indode = (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? @@ -64,6 +94,78 @@ async function main() { }), )); + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ?? + (await wagonTypeRepo.save( + wagonTypeRepo.create({ + code: 'NEGAD-FLAT', + name: 'Negad Demo Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }), + )); + + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT' } })) ?? + (await containerTypeRepo.save( + containerTypeRepo.create({ + code: '40FT', + label: '40FT', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }), + )); + + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.save( + serviceTypeRepo.create({ + code: 'RAIL_CONTAINER', + serviceName: 'Rail Container Service', + description: 'Rail container service for demo marshalling', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }), + )); + + const company = + (await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ?? + (await companyRepo.save( + companyRepo.create({ + name: 'Negad Indode Marshalling Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: 'NEGADIND01', + vatNumber: 'NEGADIND01', + fanNumber: 'NEGADINDODE00001', + country: 'Ethiopia', + address: 'Indode Dry Port', + phone: '251900000202', + email: 'negad-indode-demo@edr.local', + contactPersonName: 'Marshalling Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: 'negad-indode-demo@edr.local', + generalManagerPhone: '251900000202', + }), + )); + const now = new Date(); const departure = addHours(now, -12); const arrival = now; @@ -75,7 +177,7 @@ async function main() { locomotiveId: locomotive.id, totalWeightTons: 960, totalLengthMeters: 420, - wagonCount: 18, + wagonCount: BOOKING_REFS.length, status: 'COMPLETED', }), ); @@ -111,9 +213,169 @@ async function main() { } const saved = await scheduleRepo.save(schedule); + await trainSetRepo.update(saved.trainSetId, { + totalWeightTons: BOOKING_REFS.length * 28, + totalLengthMeters: BOOKING_REFS.length * 14, + wagonCount: BOOKING_REFS.length, + status: 'COMPLETED', + }); + + const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } }); + const existingAllocations = existingSlots.length + ? await allocationRepo.find({ + where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })), + }) + : []; + if (existingAllocations.length) { + await containerItemRepo.delete( + existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })), + ); + } + if (existingSlots.length) { + await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id }))); + await wagonRepo.update( + existingSlots.map((slot) => ({ trainSetWagonId: slot.id })), + { + trainSetWagonId: null, + currentTrainScheduleId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }, + ); + await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId }); + } + + for (const [index, reference] of BOOKING_REFS.entries()) { + const sequenceNo = index + 1; + const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`; + const weightTons = 26 + sequenceNo; + + let booking = await bookingRepo.findOne({ where: { reference } }); + if (!booking) { + booking = bookingRepo.create({ reference }); + } + Object.assign(booking, { + companyId: company.id, + originYardId: negad.id, + destinationYardId: indode.id, + serviceTypeId: serviceType.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: departure, + estimatedShipmentDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: `Negad to Indode demo container ${sequenceNo}`, + cargoTotalWeightVgm: weightTons, + priorityScore: 75 - index, + trainScheduleId: saved.id, + schedulingStatus: 'SCHEDULED', + scheduledAt: now, + wagonsRequired: 1, + }); + booking = await bookingRepo.save(booking); + + await bookingContainerRepo.delete({ bookingId: booking.id }); + const bookingContainer = await bookingContainerRepo.save( + bookingContainerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType.id, + containerNumber, + quantity: 1, + vgmPerUnitTons: weightTons, + totalVgmTons: weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }), + ); + + await scheduleBookingRepo.upsert( + { trainScheduleId: saved.id, bookingId: booking.id }, + { conflictPaths: { trainScheduleId: true, bookingId: true } }, + ); + + const wagon = await wagonRepo.save( + wagonRepo.create({ + wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`, + wagonTypeId: wagonType.id, + trainId: null, + sequenceNumber: sequenceNo, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Assigned, + currentYardId: indode.id, + notes: 'Demo wagon for Negad to Indode marshalling', + trainSetWagonId: null, + currentTrainScheduleId: saved.id, + }), + ); + + const trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + trainSetId: saved.trainSetId, + wagonTypeId: wagonType.id, + physicalWagonId: wagon.id, + sequenceNo, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: weightTons, + status: 'LOADED', + }), + ); + await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: weightTons, + loadType: 'CONTAINER', + status: 'LOADED', + confirmedAt: now, + }), + ); + + await containerItemRepo.save( + containerItemRepo.create({ + wagonBookingAllocationId: allocation.id, + bookingContainerId: bookingContainer.id, + containerId: null, + containerNumber, + containerTypeId: containerType.id, + positionOnWagon: 1, + sealNumber: `SEAL-${containerNumber}`, + grossWeightTons: weightTons, + }), + ); + } + + await importOperationRepo.upsert( + { + trainScheduleId: saved.id, + documents: {}, + gatepassGrantedAt: departure, + readyForLoadingAt: departure, + loadedOnTrainAt: departure, + departedFromDjiboutiAt: departure, + loadListGeneratedAt: now, + performedBy: 'Seed Demo', + notes: 'Seeded marshalling data for Negad to Indode arrived train', + }, + { conflictPaths: { trainScheduleId: true } }, + ); + console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`); console.log(`Schedule ID: ${saved.id}`); console.log(`Route: ${negad.code} -> ${indode.code}`); + console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`); }); } finally { await dataSource.destroy(); 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-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts new file mode 100644 index 000000000..2d3c47c26 --- /dev/null +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -0,0 +1,474 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { WagonStatus } from '@edr/types'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +type DemoDirection = 'IMPORT' | 'EXPORT'; + +interface DemoTrain { + trainNumber: string; + direction: DemoDirection; + status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED'; + bookingPrefix: string; + departureOffsetHours: number; +} + +const DEMO_TRAINS: DemoTrain[] = [ + { + trainNumber: 'MSH-DEMO-IMP-01', + direction: 'IMPORT', + status: 'SCHEDULED', + bookingPrefix: 'MSH-IMP-01', + departureOffsetHours: 6, + }, + { + trainNumber: 'MSH-DEMO-IMP-02', + direction: 'IMPORT', + status: 'DISPATCHED', + bookingPrefix: 'MSH-IMP-02', + departureOffsetHours: -3, + }, + { + trainNumber: 'MSH-DEMO-IMP-03', + direction: 'IMPORT', + status: 'ARRIVED', + bookingPrefix: 'MSH-IMP-03', + departureOffsetHours: -14, + }, + { + trainNumber: 'MSH-DEMO-EXP-01', + direction: 'EXPORT', + status: 'SCHEDULED', + bookingPrefix: 'MSH-EXP-01', + departureOffsetHours: 8, + }, + { + trainNumber: 'MSH-DEMO-EXP-02', + direction: 'EXPORT', + status: 'DISPATCHED', + bookingPrefix: 'MSH-EXP-02', + departureOffsetHours: -2, + }, + { + trainNumber: 'MSH-DEMO-EXP-03', + direction: 'EXPORT', + status: 'ARRIVED', + bookingPrefix: 'MSH-EXP-03', + departureOffsetHours: -12, + }, +]; + +@Injectable() +export class MarshallingDemoTrainsSeeder { + private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const wagonTypeRepo = this.dataSource.getRepository(WagonType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + + const djiboutiYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const ethiopiaYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ?? + (await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ?? + (await wagonTypeRepo.findOne({ where: { isActive: true } })); + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const warehouseYard = warehouse + ? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }) + : null; + const warehouseZone = warehouseYard + ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) + : null; + + const missing = [ + !djiboutiYard ? 'Djibouti yard' : '', + !ethiopiaYard ? 'Ethiopia yard' : '', + !serviceType ? 'service type' : '', + !wagonType ? 'wagon type' : '', + !warehouse ? 'INDODE_OPEN warehouse' : '', + !warehouseYard ? 'warehouse yard' : '', + !warehouseZone ? 'warehouse zone' : '', + ].filter(Boolean); + if (missing.length) { + this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); + return; + } + + let seeded = 0; + for (const demo of DEMO_TRAINS) { + const created = await this.seedTrain(demo, { + djiboutiYard: djiboutiYard!, + ethiopiaYard: ethiopiaYard!, + serviceType: serviceType!, + cargoType, + wagonType: wagonType!, + warehouse: warehouse!, + warehouseYard: warehouseYard!, + warehouseZone: warehouseZone!, + }); + if (created) seeded += 1; + } + + this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`); + } catch (error) { + this.logger.error( + `MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async seedTrain( + demo: DemoTrain, + refs: { + djiboutiYard: Yard; + ethiopiaYard: Yard; + serviceType: ServiceType; + cargoType: CargoType | null; + wagonType: WagonType; + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation); + const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existing) { + await this.backfillDispatchQueueInventory(demo, refs); + return false; + } + + const now = new Date(); + const departure = this.addHours(now, demo.departureOffsetHours); + const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10); + const isDispatched = demo.status === 'DISPATCHED'; + const isArrived = demo.status === 'ARRIVED'; + const hasDeparted = isDispatched || isArrived; + const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + + const locomotive = await this.ensureLocomotive(originYard.id); + const bookingWeights = [22.4, 24.8, 18.6, 20.2]; + const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); + const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; + const wagonLength = Number(refs.wagonType.lengthMeters) || 14; + const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: totalWeight, + totalLengthMeters: wagonLength * bookingWeights.length, + wagonCount: bookingWeights.length, + status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard.id, + destinationStationId: destinationYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: hasDeparted ? departure : null, + actualArrivalAt: isArrived ? arrival : null, + status: demo.status as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: demo.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }), + ); + + for (const [index, weightTons] of bookingWeights.entries()) { + const sequence = index + 1; + const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`; + const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`; + + const booking = await bookingRepo.save( + bookingRepo.create({ + reference: bookingReference, + originYardId: originYard.id, + destinationYardId: destinationYard.id, + serviceTypeId: refs.serviceType.id, + status: hasDeparted ? 'IN_TRANSIT' : 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: demo.direction, + freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER', + cargoTypeId: refs.cargoType?.id ?? null, + cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`, + cargoTotalWeightVgm: weightTons * 1000, + trainScheduleId: schedule.id, + schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED', + scheduledAt: now, + }), + ); + await this.ensureDispatchQueueInventory({ + booking, + demo, + refs, + weightKg: weightTons * 1000, + now, + }); + + const physicalWagon = await this.ensureWagon({ + wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, + wagonTypeId: refs.wagonType.id, + yardId: originYard.id, + trainScheduleId: schedule.id, + tareWeight, + capacityTons: wagonCapacity, + dispatched: hasDeparted, + }); + + const trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + trainSetId: trainSet.id, + wagonTypeId: refs.wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: sequence, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: weightTons, + status: hasDeparted ? 'DEPARTED' : 'LOADED', + }), + ); + + await this.dataSource.getRepository(Wagon).update(physicalWagon.id, { + trainSetWagonId: trainSetWagon.id, + }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: weightTons, + loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK', + status: hasDeparted ? 'DEPARTED' : 'LOADED', + confirmedAt: now, + }), + ); + + await containerItemRepo.save( + containerItemRepo.create({ + wagonBookingAllocationId: allocation.id, + containerNumber, + positionOnWagon: 1, + sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`, + chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`, + grossWeightTons: weightTons, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + if (demo.direction === 'IMPORT') { + await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted); + } + return true; + } + + private async backfillDispatchQueueInventory( + demo: DemoTrain, + refs: { + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + for (let sequence = 1; sequence <= 4; sequence++) { + const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`; + const booking = await bookingRepo.findOne({ where: { reference } }); + if (!booking) continue; + await this.ensureDispatchQueueInventory({ + booking, + demo, + refs, + weightKg: Number(booking.cargoTotalWeightVgm) || 0, + now: new Date(), + }); + } + } + + private async ensureDispatchQueueInventory(input: { + booking: Booking; + demo: DemoTrain; + refs: { + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }; + weightKg: number; + now: Date; + }): Promise { + const repo = this.dataSource.getRepository(WarehouseInventory); + const existing = await repo.findOne({ where: { bookingId: input.booking.id } }); + if (existing) return; + + const exportDispatch = input.demo.direction === 'EXPORT'; + const arrivedAt = this.addHours(input.now, -8); + const inspectedAt = this.addHours(input.now, -6); + const readyAt = this.addHours(input.now, -4); + const loadedAt = this.addHours(input.now, -2); + + await repo.save( + repo.create({ + warehouseId: input.refs.warehouse.id, + yardId: input.refs.warehouseYard.id, + zoneId: input.refs.warehouseZone.id, + bookingId: input.booking.id, + quantity: 1, + weight: input.weightKg, + status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP', + inspectionStatus: 'PASSED', + arrivedAt, + unloadedAt: exportDispatch ? null : arrivedAt, + inspectedAt, + readyForLoadingAt: exportDispatch ? readyAt : null, + loadedAt: exportDispatch ? loadedAt : null, + readyForPickupAt: exportDispatch ? null : readyAt, + notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`, + }), + ); + } + + private async ensureLocomotive(currentYardId: string): Promise { + const repo = this.dataSource.getRepository(Locomotive); + const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } }); + if (existing) return existing; + return repo.save( + repo.create({ + code: 'MSH-DEMO-LOCO', + name: 'Marshalling Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId, + }), + ); + } + + private async ensureWagon(input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + tareWeight: number; + capacityTons: number; + dispatched: boolean; + }): Promise { + const repo = this.dataSource.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + if (existing) return existing; + return repo.save( + repo.create({ + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: WagonStatus.Assigned, + notes: 'Marshalling demo seed wagon', + }), + ); + } + + private async seedImportOperation( + trainScheduleId: string, + trainNumber: string, + now: Date, + departure: Date, + dispatched: boolean, + ): Promise { + const repo = this.dataSource.getRepository(ImportDjiboutiOperation); + await repo.save( + repo.create({ + trainScheduleId, + documents: { + DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now), + PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now), + DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now), + ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now), + RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now), + }, + gatepassGrantedAt: now, + readyForLoadingAt: now, + loadedOnTrainAt: now, + departedFromDjiboutiAt: dispatched ? departure : null, + performedBy: 'Marshalling Demo Seeder', + notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow', + }), + ); + } + + private documentRecord(trainNumber: string, type: string, now: Date) { + return { + reference: `${type}-${trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Marshalling Demo Seeder', + notes: 'Seeded document for import marshalling workflow', + }; + } + + private addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ca6c1b405..542a848a4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -73,6 +73,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; +import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; @@ -210,6 +212,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ // }, ], }, + { + title: "Port & Terminal", + items: [ + { + label: "Import Operations", + href: "/dashboard/import-warehouse", + icon: , + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + ], + }, + { + label: "Export Operations", + href: "/dashboard/export-warehouse", + icon: , + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + ], + }, + ], + }, { title: "Warehouse Management", items: [ @@ -223,46 +304,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/warehouses", icon: , }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - }, { label: "Allocation & Fees", href: "/dashboard/warehouse-rules", @@ -487,6 +528,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index a8f0ca1ef..f37db855d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + const openHandoverDocument = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadHandoverDocument(item.id); + const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(response.data, filename, pdfWindow); + toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Handover document failed', + description: extractErrorMessage(error), + }); + } finally { + setBusyId(null); + } + }; + const acceptLastMile = async (item: WarehouseInventoryItem) => { const reference = item.booking?.reference; if (!reference) { @@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onInspect={setInspectItem} onFeePreview={setFeeItem} onReleaseDocument={downloadReleaseDocument} + onHandoverDocument={openHandoverDocument} onLastMile={onLastMile ? acceptLastMile : undefined} selectedIds={selected} onToggleSelect={toggleSelect} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index e2ff9fb3a..eb320d78b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,5 +1,6 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { + ActionIcon, Alert, Badge, Button, @@ -8,6 +9,7 @@ import { Loader, Modal, NumberInput, + ScrollArea, Select, Stack, Table, @@ -15,28 +17,58 @@ import { Text, Textarea, TextInput, + Tooltip, } from '@mantine/core'; -import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + ClipboardCheck, + Eye, + FileText, + History, + Info, + PackageCheck, + PackageOpen, + PackageSearch, + Send, + Search, + Train, + Truck, +} from 'lucide-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { useInventoryInquiry } from '@/hooks/useWarehouses'; import { firstMileService } from '@/services/first-mile.service'; +import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, + InventoryInquiryFilter, + InventoryInquiryResult, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, TruckEntrancePayload, + WarehouseInventoryItem, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; +import { DeliverInventoryModal } from './DeliverInventoryModal'; +import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; +import { InventoryDetailModal } from './InventoryDetailModal'; +import { InventoryHistoryModal } from './InventoryHistoryModal'; import { InventoryWorkbench } from './InventoryWorkbench'; -import { extractErrorMessage, formatDate, formatNumber } from './options'; +import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal'; +import { ReleaseOrderModal } from './ReleaseOrderModal'; +import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { openPdfBlob } from './pdf'; +import '@/components/overview/overview.css'; interface ReceiveInventoryModalProps { opened: boolean; @@ -608,6 +640,7 @@ function EligibleTab({ const [statusTab, setStatusTab] = useState('ALL'); const [truckOpen, setTruckOpen] = useState(false); const [pendingReceiveIds, setPendingReceiveIds] = useState([]); + const [receivedAt, setReceivedAt] = useState(null); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const [lockedTruckFields, setLockedTruckFields] = useState({}); const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); @@ -717,6 +750,7 @@ function EligibleTab({ setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); + setReceivedAt(null); setLockedTruckFields({}); setPackagingFreightType('MIXED'); onChanged?.(); @@ -749,6 +783,7 @@ function EligibleTab({ } const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); + setReceivedAt(new Date().toISOString()); setTruckForm(form); setLockedTruckFields(lockedFields); setPackagingFreightType(nextPackagingFreightType); @@ -807,15 +842,18 @@ function EligibleTab({ )} )} @@ -967,7 +1015,7 @@ function EligibleTab({ setTruckOpen(false)} - title="Export Truck Arrival / First Mile Receive Form" + title="Receive to Warehouse" centered size="lg" > @@ -979,6 +1027,52 @@ function EligibleTab({ : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + + + + + Booking + Customer + TIN / Phone + Container / Cargo + Qty / Package + Weight + Received at + + + + {pendingReceiveRows.map((booking) => ( + + + {booking.reference} + {booking.id.slice(0, 8)}... + + {booking.customer ?? '-'} + + + {booking.customerTin ?? '-'} + {booking.customerPhone ?? '-'} + + + + + {booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'} + {booking.freightType ?? '-'} + + + + {[ + booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null, + booking.containerPackagingType, + ].filter(Boolean).join(' / ') || '-'} + + {formatNumber(Number(booking.weight))} + {formatDate(receivedAt)} + + ))} + +
+
Booking ID Customer ID Customer Name - Container # + Container / Cargo Items Cargo Type Weight Route @@ -1466,11 +1560,11 @@ function LoadedExportTab({ } /** Assigned bookings/items for an arrived import train (read-only detail view). */ -function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { +function ImportTrainDetailTable({ train }: { train: ImportTrain }) { const { data: items = [], isLoading } = useQuery( api.warehouses.importTrainItems.queryOptions({ - input: { scheduleId }, - enabled: Boolean(scheduleId), + input: { scheduleId: train.scheduleId }, + enabled: Boolean(train.scheduleId), }), ); @@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { + Wagon Booking ID Booking Ref Customer ID @@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { {items.map((it: ImportTrainItem) => ( - + + + + {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} + + {it.bookingId.slice(0, 8)}… @@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({ {t.totalCargoes} - - {fullyUnloaded ? 'UNLOADED' : t.status} + + {t.status} {Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded @@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({ {isOpen && ( - + )} @@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({ */ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); + const qc = useQueryClient(); const { data: rows = [], isLoading } = useQuery( api.warehouses.importUnloadedQueue.queryOptions({ enabled }), ); const inspectMutation = useMutation( api.warehouses.bulkMarkInspected.mutationOptions(), ); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); + const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [viewItem, setViewItem] = useState(null); + const [historyItem, setHistoryItem] = useState(null); + const [feeItem, setFeeItem] = useState(null); + const [releaseItem, setReleaseItem] = useState(null); + const [deliverItem, setDeliverItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, }); setSelected(new Set()); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); } }; + const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem => + ({ + id: row.id, + bookingId: row.bookingId, + quantity: 1, + weight: Number(row.weight) || 0, + status: row.currentStatus, + arrivedAt: row.arrivalTime, + unloadedAt: row.arrivalTime, + inspectionStatus: row.inspectionStatus, + releaseDate: row.releaseDate, + releaseOrderReference: row.releaseOrderReference, + deliveredAt: row.deliveredAt, + booking: row.bookingId + ? { + id: row.bookingId, + reference: row.bookingReference ?? row.bookingId, + tradeDirection: 'IMPORT', + lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null, + } + : null, + }) as unknown as WarehouseInventoryItem; + + const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise) => { + setBusyId(row.id); + try { + await fn(); + toast({ title: label }); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + } catch (error) { + toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + + const openHandoverDocument = async (row: ImportUnloadedItem) => { + setBusyId(row.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadHandoverDocument(row.id); + openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + return ( @@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {r.currentStatus} - + + + setViewItem(toInventoryItem(r))}> + + + + {r.currentStatus === 'UNLOADED' && ( + + )} + {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + <> + + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && ( + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + + )} + {r.inspectionStatus === 'PASSED' && ( + + )} + + + setFeeItem(toInventoryItem(r))}> + + + + + setHistoryItem(toInventoryItem(r))}> + + + + ))} @@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { onClose={() => setInspectId(null)} inventoryId={inspectId} /> + setViewItem(null)} item={viewItem} /> + setHistoryItem(null)} item={historyItem} /> + setFeeItem(null)} + inventoryId={feeItem?.id ?? null} + /> + setReleaseItem(null)} item={releaseItem} /> + setDeliverItem(null)} item={deliverItem} /> ); } @@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) { ); } -/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ -function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { - const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); - const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT'); +type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH'; +type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking'; +type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking'; - useEffect(() => { - if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' }); - }, [opened]); +interface WarehouseQueueTab { + value: TValue; + label: string; + icon: React.ReactNode; + count?: number; +} + +interface WarehouseFlowWorkbenchProps { + direction?: WarehouseFlowDirection; + enabled?: boolean; + onChanged?: () => void; +} + +function WarehouseQueueTabs({ + value, + onChange, + tabs, +}: { + value: TValue; + onChange: (value: TValue) => void; + tabs: WarehouseQueueTab[]; +}) { + return ( + onChange((next as TValue) ?? value)} + variant="pills" + color="edr-green" + keepMounted={false} + classNames={{ list: 'ov-tablist', tab: 'ov-tab' }} + > + + + {tabs.map((tab) => { + const active = value === tab.value; + return ( + + {tab.count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + + + ); +} + +function LocateBookingTab({ enabled }: { enabled: boolean }) { + const [draft, setDraft] = useState({}); + const [applied, setApplied] = useState({}); + const [viewResult, setViewResult] = useState(null); + const hasSearch = Boolean( + applied.bookingReference || + applied.containerNumber || + applied.goodsName || + applied.cargoType || + applied.status, + ); + const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch); + + const normalizeDraft = (): InventoryInquiryFilter => ({ + bookingReference: draft.bookingReference?.trim() || undefined, + containerNumber: draft.containerNumber?.trim() || undefined, + goodsName: draft.goodsName?.trim() || undefined, + cargoType: draft.cargoType?.trim() || undefined, + status: draft.status, + }); + + const runSearch = () => setApplied(normalizeDraft()); + const reset = () => { + setDraft({}); + setApplied({}); + }; return ( - - - + + + setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={230} + /> + setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={220} + /> + { + const value = e.currentTarget.value || undefined; + setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value })); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={200} + /> + + @@ -312,6 +314,7 @@ const FirstMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -330,6 +333,8 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); const [distanceValue, setDistanceValue] = useState(""); + const [invoiceOpen, setInvoiceOpen] = useState(false); + const [invoiceRecord, setInvoiceRecord] = useState(null); const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), @@ -347,6 +352,14 @@ const FirstMilePage = () => { }, }); + const { data: ratesData } = useQuery({ + queryKey: ["rates", "FIRST_MILE"], + queryFn: async () => { + const res = await ratesService.getByType("FIRST_MILE"); + return res.data; + }, + }); + const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }), queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }), @@ -376,7 +389,7 @@ const FirstMilePage = () => { mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) => firstMileService.update(id, data), onSuccess: () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -384,10 +397,10 @@ const FirstMilePage = () => { }); const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) => - firstMileService.update(id, { exactKm }), + mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => + firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), onSuccess: () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); if (activeRecord) { toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); } @@ -485,16 +498,39 @@ const FirstMilePage = () => { setDistanceValue(""); }; + const openInvoice = (record: FirstMileRecord) => { + setInvoiceRecord(record); + setInvoiceOpen(true); + }; + + const closeInvoice = () => { + setInvoiceOpen(false); + setInvoiceRecord(null); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); return; } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance }); + + let remainingPayment: number | undefined; + if (ratesData?.data) { + const firstMileRate = ratesData.data.find( + (r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") + ); + if (firstMileRate) { + const rateValue = parseFloat(firstMileRate.rateValue); + remainingPayment = distance * rateValue; + } + } + + updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -532,7 +568,7 @@ const FirstMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -701,6 +737,27 @@ const FirstMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , }, + { + id: "invoice", + header: "Invoice", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + if (!hasDistance) { + return ; + } + return ( + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + ); + }, + }, { id: "status", header: "Status", @@ -767,9 +824,10 @@ const FirstMilePage = () => { View detail } onClick={() => openDistance(row.original.id)} > - Add Actual distance + Add distance {canPrint && ( { ); })} + @@ -1122,6 +1191,87 @@ const FirstMilePage = () => { + + {/* Invoice modal */} + Invoice #345} + size="lg" + radius="lg" + centered + > + + {invoiceRecord && ( + <> + + + + EDR Freight + Invoice #345 + + + + + + + + + + + + + + + + + Post Payment + {formatPrice(invoiceRecord.remainingPayment)} + + + Advanced Payment + {formatPrice(invoiceRecord.advancedPayment)} + + + {(() => { + const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); + const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); + const difference = postPayment - advancedPayment; + + if (difference > 0) { + return ( + + Remaining to Pay + {formatPrice(difference)} + + ); + } else if (difference < 0) { + return ( + + Refund + {formatPrice(Math.abs(difference))} + + ); + } else { + return ( + + Status + Settled + + ); + } + })()} + + + + + + )} + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 5535809a2..9798a90bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -5,6 +5,7 @@ import { MoreHorizontal, Printer, RefreshCw, + Ruler, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -21,12 +22,14 @@ import { Group, Menu, Modal, + NumberInput, ScrollArea, Select, SimpleGrid, Stack, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem } from "@/types/warehouse"; import { warehouseService } from "@/services/warehouse.service"; @@ -41,6 +44,7 @@ import { lastMileService, } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; +import { ratesService } from "@/services/rates.service"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -294,6 +298,7 @@ const LastMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -311,6 +316,11 @@ const LastMilePage = () => { const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); const [arrivalSearch, setArrivalSearch] = useState(""); + const [distanceOpen, setDistanceOpen] = useState(false); + const [distanceValue, setDistanceValue] = useState(""); + const [invoiceOpen, setInvoiceOpen] = useState(false); + const [invoiceRecord, setInvoiceRecord] = useState(null); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.LAST_MILE.list(), queryFn: async () => { @@ -327,6 +337,14 @@ const LastMilePage = () => { }, }); + const { data: ratesData } = useQuery({ + queryKey: ["rates", "LAST_MILE"], + queryFn: async () => { + const res = await ratesService.getByType("LAST_MILE"); + return res.data; + }, + }); + const records = listData?.data ?? []; const vehicleOptions = useMemo( @@ -352,6 +370,21 @@ const LastMilePage = () => { }, }); + const updateDistanceMutation = useMutation({ + mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => + lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); + if (activeRecord) { + toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); + } + closeDistance(); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), @@ -422,6 +455,49 @@ const LastMilePage = () => { acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue }); }; + const openDistance = (id: string) => { + setActiveId(id); + setDistanceValue(""); + setDistanceOpen(true); + }; + + const closeDistance = () => { + setDistanceOpen(false); + setActiveId(null); + setDistanceValue(""); + }; + + const openInvoice = (record: LastMileRecord) => { + setInvoiceRecord(record); + setInvoiceOpen(true); + }; + + const closeInvoice = () => { + setInvoiceOpen(false); + setInvoiceRecord(null); + }; + + const handleSaveDistance = () => { + const distance = parseFloat(distanceValue); + if (!activeId || isNaN(distance) || distance < 0) { + toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + return; + } + + let remainingPayment: number | undefined; + if (ratesData?.data) { + const lastMileRate = ratesData.data.find( + (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") + ); + if (lastMileRate) { + const rateValue = parseFloat(lastMileRate.rateValue); + remainingPayment = distance * rateValue; + } + } + + updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + }; + const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -433,6 +509,7 @@ const LastMilePage = () => { ); const matchesFilter = (r: LastMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -470,7 +547,7 @@ const LastMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -639,6 +716,27 @@ const LastMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , }, + { + id: "invoice", + header: "Invoice", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + if (!hasDistance) { + return ; + } + return ( + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + ); + }, + }, { id: "status", header: "Status", @@ -704,6 +802,12 @@ const LastMilePage = () => { > View detail + } + onClick={() => openDistance(row.original.id)} + > + Add distance + {canPrint && ( } @@ -764,6 +868,17 @@ const LastMilePage = () => { ); })} + @@ -1000,6 +1115,134 @@ const LastMilePage = () => { + + {/* Add Actual Distance modal */} + Add Actual Distance} + size="md" + radius="lg" + centered + > + + {activeRecord && ( + + + {bookingRef(activeRecord)} + + Customer + {customerName(activeRecord)} + + + Est. Distance (KM) + {activeRecord.estimatedKm ?? "—"} + + + + )} + setDistanceValue(String(v ?? ""))} + min={0} + step={0.1} + decimalScale={2} + /> + + + + + + + + {/* Invoice modal */} + Invoice #345} + size="lg" + radius="lg" + centered + > + + {invoiceRecord && ( + <> + + + + EDR Freight + Invoice #345 + + + + + + + + + + + + + + + + + Post Payment + {formatPrice(invoiceRecord.remainingPayment)} + + + Advanced Payment + {formatPrice(invoiceRecord.advancedPayment)} + + + {(() => { + const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); + const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); + const difference = postPayment - advancedPayment; + + if (difference > 0) { + return ( + + Remaining to Pay + {formatPrice(difference)} + + ); + } else if (difference < 0) { + return ( + + Refund + {formatPrice(Math.abs(difference))} + + ); + } else { + return ( + + Status + Settled + + ); + } + })()} + + + + + + )} + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index af8653069..aa594fdd0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -296,9 +296,15 @@ export default function TrainScheduleV2DetailPage() { const canDispatch = schedule.status === "SCHEDULED"; const finalizeStep = hasContainerStep ? 3 : 2; const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); - const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT"; + const canPrintMarshalling = + (schedule.direction === "IMPORT" || schedule.direction === "EXPORT") && + ["DISPATCHED", "ARRIVED"].includes(schedule.status); - const openMarshallingDocument = async () => { + const openMarshallingDocument = async (options?: { + title?: string; + successDescription?: string; + errorTitle?: string; + }) => { const pdfWindow = window.open("", "_blank"); try { const blob = await downloadMarshalling.mutateAsync({ @@ -309,15 +315,17 @@ export default function TrainScheduleV2DetailPage() { const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`; const opened = openPdfBlob(blob, filename, pdfWindow); toast({ - title: "Marshalling document ready", - description: opened - ? "The PDF opened in a browser tab for printing or saving." - : "The browser blocked the preview tab, so the PDF was downloaded.", + title: options?.title ?? "Marshalling document ready", + description: + options?.successDescription ?? + (opened + ? "The PDF opened in a browser tab for printing or saving." + : "The browser blocked the preview tab, so the PDF was downloaded."), }); } catch (error) { pdfWindow?.close(); toast({ - title: "Could not open marshalling document", + title: options?.errorTitle ?? "Could not open marshalling document", description: parseError(error, "Make sure the train has wagon allocations, then try again."), variant: "destructive", }); @@ -708,7 +716,11 @@ export default function TrainScheduleV2DetailPage() { onClick={async () => { try { await dispatch.mutateAsync(scheduleId); - toast({ title: "Train dispatched" }); + await openMarshallingDocument({ + title: "Train dispatched", + successDescription: "Marshalling document generated for the dispatched train.", + errorTitle: "Train dispatched, but document could not open", + }); } catch (err) { toast({ title: "Dispatch failed", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 8beb0cf25..4de1f5369 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -231,8 +231,8 @@ export default function ArrivalQueuePage() { {train.totalCargoes} - - {fullyUnloaded ? 'UNLOADED' : train.status} + + {train.status} {Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 1aeeb7cb8..31ee5ea36 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -66,14 +66,14 @@ const statusLabel = (status?: string | null) => : (status ?? 'PENDING').replace(/_/g, ' '); function ExportTrainDetailRows({ - scheduleId, + train, onOpenHistory, }: { - scheduleId: string; + train: ExportTrain; onOpenHistory: (inventoryId: string) => void; }) { const navigate = useNavigate(); - const { data: items = [], isLoading } = useExportDjiboutiTrainItems(scheduleId); + const { data: items = [], isLoading } = useExportDjiboutiTrainItems(train.scheduleId); if (isLoading) { return ( @@ -92,10 +92,11 @@ function ExportTrainDetailRows({ } return ( - +
+ Wagon Booking ID Booking Reference Customer ID @@ -115,6 +116,11 @@ function ExportTrainDetailRows({ {items.map((item: ExportTrainItem) => ( + + + {item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''} + + {item.bookingId.slice(0, 8)} @@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx new file mode 100644 index 000000000..bf086f1ce --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx @@ -0,0 +1,28 @@ +import { Button, Card } from '@mantine/core'; +import { PackageSearch } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseFlowWorkbench } from '@/components/warehouses'; + +export default function ExportWarehouseFlowPage() { + const navigate = useNavigate(); + + return ( + + } onClick={() => navigate('/dashboard/import-warehouse')}> + Import Operations + + } + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx new file mode 100644 index 000000000..2fd2f4f43 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx @@ -0,0 +1,28 @@ +import { Button, Card } from '@mantine/core'; +import { Truck } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseFlowWorkbench } from '@/components/warehouses'; + +export default function ImportWarehouseFlowPage() { + const navigate = useNavigate(); + + return ( + + } onClick={() => navigate('/dashboard/export-warehouse')}> + Export Operations + + } + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 057dd0c21..7133c47ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -1,13 +1,12 @@ import { useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { PackagePlus, Search } from 'lucide-react'; +import { PackageOpen, Search, Truck } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; import { InventoryWorkbench, - ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; import { @@ -19,13 +18,13 @@ import { import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined; const [filter, setFilter] = useState( initialStatus ? { status: initialStatus } : {}, ); const [search, setSearch] = useState(''); - const [modalOpen, setModalOpen] = useState(false); const [debouncedSearch] = useDebouncedValue(search, 300); const queryFilter = useMemo( @@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() { title="Warehouse Inventory" subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle." action={ - + + + + } /> @@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() { - - setModalOpen(false)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/services/rates.service.ts b/apps/edr-freight-web/backoffice/src/services/rates.service.ts new file mode 100644 index 000000000..589f3ea13 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/rates.service.ts @@ -0,0 +1,35 @@ +import { api } from '../auth/http'; + +export interface Rate { + id: string; + rateType: string; + appliesTo: string; + trigger: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + currency: string; + rateValue: string; + rateUnit: string; + status: string; + proposedByStaffId: string; + approvedByCeoId: string | null; + approvedAt: string | null; + effectiveFrom: string; + effectiveTo: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface RatesListResponse { + data: Rate[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; +} + +export const ratesService = { + list: (pageSize = 1000) => + api.get(`/rates?pageSize=${pageSize}`), + getByType: (rateType: string) => + api.get(`/rates?rateType=${rateType}&pageSize=1000`), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index a9a98a80d..83909c415 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -137,6 +137,10 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), { responseType: 'blob', }), + downloadHandoverDocument: (id: string) => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { + responseType: 'blob', + }), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 0cdccc10b..638ab98cd 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -18,6 +18,9 @@ export type CompanyType = /** Mirrors backend `CompanyStatus`. */ export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; +/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */ +export type CompanyKind = "commercial" | "government"; + /** Mirrors backend `ProfileType` (the role a company plays). */ export type ProfileType = | "importer" @@ -47,6 +50,7 @@ export interface Company { id: string; name: string; type: CompanyType; + kind: CompanyKind; status: CompanyStatus; tin: string; vatNumber?: string | null; @@ -73,6 +77,7 @@ export interface CompanyListFilter { pageSize: number; search?: string; type?: CompanyType; + kind?: CompanyKind; status?: CompanyStatus; } diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index e3285b6c1..bca62a21c 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -517,6 +517,9 @@ export interface ExportTrainItem { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; itemType: 'CONTAINER' | 'CARGO'; itemId: string | null; inventoryId: string | null; @@ -566,6 +569,9 @@ export interface ImportUnloadedItem { pickupOption: string; lastMileRequested: boolean; currentStatus: string; + releaseDate: string | null; + releaseOrderReference: string | null; + deliveredAt: string | null; } export interface ImportTrainItem { @@ -573,6 +579,9 @@ export interface ImportTrainItem { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; containerNumber: string | null; cargoType: string | null; weight: number | null; diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 341d2c7c3..6e7326189 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { defineConfig } from "vitest/config"; -import { loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const streamBrowserifyPath = require.resolve("stream-browserify"); -export default defineConfig(({ mode }) => { +export default defineConfig(() => { return { plugins: [react(), tailwindcss()], resolve: { diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 8ce1365c5..05c942290 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; -import BillingPage from "./pages/billing/BillingPage"; +import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage"; +import InvoicesList from "./pages/billing/InvoicesList"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; @@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [ icon: , }, { - label: "Billing", + label: "Invoices", href: "/billing", icon: , }, @@ -285,7 +286,8 @@ const App = () => { /> } /> } /> - } /> + } /> + } /> {/* Profile was merged into Settings — keep old links working. */} `/api/payments/intents/${bookingId}`, CHECKOUT: "/api/payments/checkout", }, + + BILLING: { + MY_INVOICES: "/api/billing/my-invoices", + MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`, + PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`, + }, }; diff --git a/apps/edr-freight-web/portal/src/lib/currency.ts b/apps/edr-freight-web/portal/src/lib/currency.ts new file mode 100644 index 000000000..d41b4edda --- /dev/null +++ b/apps/edr-freight-web/portal/src/lib/currency.ts @@ -0,0 +1,23 @@ +/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */ +export type Currency = string; + +const SYMBOLS: Record = { + USD: "$", + ETB: "Br", + DJF: "DJF", +}; + +/** + * Format a money amount with its currency symbol, e.g. `Br 12,500.00`. + * Unknown currency codes fall back to printing the raw code. + */ +export function formatCurrency( + amount: number, + currency: Currency = "ETB", +): string { + const symbol = SYMBOLS[currency] ?? currency; + return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} diff --git a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts b/apps/edr-freight-web/portal/src/lib/currentCustomer.ts deleted file mode 100644 index 47fc1efa6..000000000 --- a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { invoices, type Invoice } from "@/pages/billing/invoices.mock"; -import { customers, type Customer } from "@/pages/customers/customers.mock"; - -/** - * Mock "logged-in customer". When auth integrates, replace this with the value - * pulled from `@edr/iamui-common` / the JWT context. - */ -const CURRENT_CUSTOMER_ID = 1; - -export function getCurrentCustomer(): Customer { - return ( - customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ?? - (customers[0] as Customer) - ); -} - -export function getMyInvoices(): Invoice[] { - const me = getCurrentCustomer(); - return invoices.filter((inv) => inv.customerId === me.id); -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index cb350916d..c9e073eb5 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -1,5 +1,5 @@ -import type { Currency } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { Currency } from "@/lib/currency"; +import { formatCurrency } from "@/lib/currency"; import { Group, Grid, Select, Stack } from "@mantine/core"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -34,7 +34,6 @@ export default function MyPortalPage() { totalOutstanding, companyName, greeting, - recentInvoices, dashboard, volumePoints, maxVolume, @@ -120,7 +119,7 @@ export default function MyPortalPage() { - + diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx index 27b74cb6f..c84f94c35 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx @@ -1,7 +1,7 @@ import { Box, Group, Skeleton, Text } from "@mantine/core"; import { memo } from "react"; -import type { Currency } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { Currency } from "@/lib/currency"; +import { formatCurrency } from "@/lib/currency"; import { formatPct } from "../constants"; import { Card } from "./Card"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx index 3ebb9606f..8d350b93f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx @@ -1,8 +1,8 @@ -import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/lib/currency"; +import type { PortalInvoice } from "@/services/invoices.service"; import { Box, Group, Stack, Text } from "@mantine/core"; -import { format } from "date-fns"; -import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react"; +import { Freight } from "@edr/types"; +import { ChevronRight, Clock3 } from "lucide-react"; import { memo } from "react"; import { Link } from "react-router-dom"; import { cv, INVOICE_BADGE } from "../constants"; @@ -10,26 +10,26 @@ import { Card } from "./Card"; import { EmptyState } from "./EmptyState"; interface InvoicesSectionProps { - invoices: Array<{ - id: number; - number: string; - bookingReference: string; - amount: number; - currency: Currency; - status: InvoiceStatus; - dueDate: string; - paidDate: string | null; - }>; + invoices: PortalInvoice[]; } +/** Max rows to list in the compact dashboard card. */ +const MAX_ROWS = 5; + +const titleCase = (v: string) => + v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : ""; + export const InvoicesSection = memo(function InvoicesSection({ invoices, }: InvoicesSectionProps) { - const outstandingInvoices = invoices.filter( - (inv) => inv.status === "Sent" || inv.status === "Overdue", + // Dashboard shows unpaid invoices only (pending + overdue). + const pendingInvoices = invoices.filter( + (inv) => + inv.status === Freight.InvoiceStatus.Pending || + inv.status === Freight.InvoiceStatus.Overdue, ); - const totalOutstanding = outstandingInvoices.reduce( - (sum, inv) => sum + inv.amount, + const totalOutstanding = pendingInvoices.reduce( + (sum, inv) => sum + Number(inv.totalAmount), 0, ); @@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({ {formatCurrency(totalOutstanding || 0, "ETB")} - - - {outstandingInvoices.length || 2} invoices unpaid - - - - - Pay all - - - + + {pendingInvoices.length} invoices unpaid + - {invoices.length === 0 ? ( - + {pendingInvoices.length === 0 ? ( + ) : ( - {invoices.map((invoice, i) => { + {pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => { const badge = INVOICE_BADGE[invoice.status]; - const dueText = - invoice.status === "Paid" - ? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}` - : invoice.status === "Overdue" - ? "Overdue 3 days" - : `Due ${invoice.dueDate}`; - const DueIcon = - invoice.status === "Paid" ? CheckCircle2 : Clock3; - const dueIconColor = - invoice.status === "Paid" - ? cv("edr-green.5") - : cv("edr-muted"); + const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue; + const dueText = isOverdue + ? "Overdue" + : `Due ${new Date(invoice.dueAt).toLocaleDateString()}`; + const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted"); return ( {i > 0 && } - - + - - - {invoice.number} - - - {invoice.bookingReference} - - - - {formatCurrency(invoice.amount, invoice.currency)} - - - - - - - {dueText} + + + + {invoice.invoiceNumber} + + + {titleCase(invoice.source)} · {titleCase(invoice.type)} + + + + {formatCurrency( + Number(invoice.totalAmount), + invoice.currency, + )} - - - {badge.label} - - - - + + + + + {dueText} + + + {badge && ( + + + {badge.label} + + + )} + + + ); })} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index dae90842b..3ddd9be4d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,4 +1,4 @@ -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/lib/currency"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react"; import { memo } from "react"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 70e58f77c..ea5567ab8 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -12,7 +12,7 @@ import { Wallet, type LucideIcon, } from "lucide-react"; -import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; +import { Freight } from "@edr/types"; export const cv = (token: string) => { const [name, shade] = token.split("."); @@ -514,12 +514,13 @@ export const ACTION_PROPS: Record< }; export const INVOICE_BADGE: Record< - InvoiceStatus, + Freight.InvoiceStatus, { label: string; bg: string; text: string } > = { - Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, - Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, - Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, - Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, - Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, + [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, + [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, + [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" }, }; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index 8c1bb6b04..08a3ec97e 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -1,13 +1,14 @@ import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; -import { getMyInvoices } from "@/lib/currentCustomer"; import { api } from "@/services/api"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { const { user, customer, company } = useAuth(); - const myInvoices = useMemo(() => getMyInvoices(), []); + + const invoicesQuery = useQuery(api.invoices.listMy.queryOptions()); + const myInvoices = invoicesQuery.data ?? []; const companyProfiles = company?.company?.companyProfiles ?? []; @@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) { ).length; const outstandingInvoices = myInvoices.filter( - (inv) => inv.status === "Sent" || inv.status === "Overdue", + (inv) => + inv.status === Freight.InvoiceStatus.Pending || + inv.status === Freight.InvoiceStatus.Overdue, ); const totalOutstanding = outstandingInvoices.reduce( - (sum, inv) => sum + inv.amount, + (sum, inv) => sum + Number(inv.totalAmount), 0, ); @@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) { bookingsQuery, dashboardQuery, contractsQuery, + invoicesQuery, allContracts, recentContracts, activeContractsCount, diff --git a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx deleted file mode 100644 index 8b7ac4557..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { useMemo, useState } from "react"; -import { - AlertCircle, - Clock, - DollarSign, - Download, - Filter, - MoreHorizontal, - Pencil, - Plus, - Receipt, - Search, - Trash2, -} from "lucide-react"; - -import Breadcrumbs from "@/components/Breadcrumbs"; -import NewInvoicePage from "./NewInvoicePage"; -import DeleteInvoiceDialog from "./DeleteInvoiceDialog"; -import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock"; -import { - DataTable, - DataTableFooter, - type ColumnDef, - usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@edr/ui-common"; - -type FilterValue = "All" | InvoiceStatus; - -const FILTERS: FilterValue[] = [ - "All", - "Draft", - "Sent", - "Paid", - "Overdue", - "Cancelled", -]; - -export default function BillingPage() { - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [filter, setFilter] = useState("All"); - const [query, setQuery] = useState(""); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return invoices.filter((inv) => { - if (filter !== "All" && inv.status !== filter) return false; - if (!q) return true; - return ( - inv.number.toLowerCase().includes(q) || - inv.customer.toLowerCase().includes(q) || - inv.bookingReference.toLowerCase().includes(q) - ); - }); - }, [filter, query]); - - const total = filtered.length; - const pageCount = Math.ceil(total / pagination.pageSize); - const start = pagination.pageIndex * pagination.pageSize; - const end = Math.min(start + pagination.pageSize, total); - - const paginatedData = useMemo( - () => filtered.slice(start, end), - [start, end, filtered], - ); - - const totalRevenue = invoices - .filter((inv) => inv.status === "Paid" && inv.currency === "USD") - .reduce((sum, inv) => sum + inv.amount, 0); - const outstanding = invoices - .filter( - (inv) => - (inv.status === "Sent" || inv.status === "Overdue") && - inv.currency === "USD", - ) - .reduce((sum, inv) => sum + inv.amount, 0); - const overdueCount = invoices.filter( - (inv) => inv.status === "Overdue", - ).length; - - const columns: ColumnDef<(typeof invoices)[number]>[] = [ - { - id: "invoice", - header: "Invoice", - cell: ({ row }) => { - const inv = row.original; - return ( -
-
- -
-
-

{inv.number}

-

Issued {inv.issueDate}

-
-
- ); - }, - }, - { - accessorKey: "customer", - header: "Customer", - }, - { - accessorKey: "bookingReference", - header: "Booking", - }, - { - id: "amount", - header: "Amount", - cell: ({ row }) => { - const inv = row.original; - return ( - - {formatCurrency(inv.amount, inv.currency)} - - ); - }, - }, - { - accessorKey: "dueDate", - header: "Due Date", - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => , - }, - { - id: "actions", - size: 40, - cell: ({ row }) => { - const invoice = row.original; - return ( -
e.stopPropagation()} - > - - - - - - - - Download - - - e.preventDefault()}> - - Edit - - - - - e.preventDefault()} - variant="destructive" - > - - Void - - - - -
- ); - }, - }, - ]; - - return ( -
-
- - - -
-

- Billing -

-

- Manage invoices, payments, and financial records. -

-
- -
-
- - { - setQuery(e.target.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - placeholder="Search invoices..." - className="pl-8!" - /> -
- - - - -
-
- -
- - -
-

Total Revenue (USD)

-

- {formatCurrency(totalRevenue, "USD")} -

-
-
- -
-
-
- - - -
-

Outstanding (USD)

-

- {formatCurrency(outstanding, "USD")} -

-
-
- -
-
-
- - - -
-

Overdue Invoices

-

- {overdueCount} -

-
-
- -
-
-
-
- - -
- {FILTERS.map((f) => { - const isActive = f === filter; - const count = - f === "All" - ? invoices.length - : invoices.filter((inv) => inv.status === f).length; - return ( - - ); - })} -
-
- - - -
- Invoices - - Issued invoices and their payment status. - -
- - -
- - - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - -
-
-
- ); -} - -function StatusBadge({ status }: { status: InvoiceStatus }) { - const styles: Record = { - Draft: "bg-slate-100 text-slate-600", - Sent: "bg-sky-100 text-sky-700", - Paid: "bg-emerald-100 text-emerald-700", - Overdue: "bg-red-100 text-red-700", - Cancelled: "bg-amber-100 text-amber-700", - }; - - return ( - - {status} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx b/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx deleted file mode 100644 index a4e278cb1..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactNode } from "react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import { Button } from "@/components/ui/button"; - -export interface DeleteInvoiceDialogProps { - invoiceNumber: string; - onConfirm?: () => void; - children: ReactNode; -} - -export default function DeleteInvoiceDialog({ - invoiceNumber, - onConfirm, - children, -}: DeleteInvoiceDialogProps) { - return ( - - {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 */} -
- -