diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..fcd560a95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,7 +50,7 @@ jobs: SERVICES=() - NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index a9965c74a..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -3,6 +3,10 @@ FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat +# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit +# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app @@ -14,6 +18,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ + --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile FROM base AS builder @@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..." FROM base AS deployer COPY --from=builder /app/ . -RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat @@ -34,4 +40,4 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 -CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"] +CMD ["node", "dist/main.js"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index f8b2b27fe..b24cf6c83 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", "predev": "pnpm run clean", - "dev": "nest start --watch", + "dev": "nest start --watch --clearScreen false", "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", @@ -18,6 +18,7 @@ "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", + "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", @@ -31,7 +32,8 @@ "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", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -83,13 +85,15 @@ "@types/node": "^20.14.0", "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", + "@types/vorpal": "^1.12.8", "jest": "^29.7.0", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vorpal": "^1.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -69,6 +69,8 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; +import { FuelModule } from './modules/fuel/fuel.module'; +import { MaintenanceModule } from './modules/maintenance/maintenance.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; @@ -133,6 +135,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera OverviewModule, VehiclesModule, DriversModule, + FuelModule, + MaintenanceModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts new file mode 100644 index 000000000..b2026b753 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.last_mile_container_allocations table — container allocation + * records linking last-mile deliveries with containers and vehicles. + */ +export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'last_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 5c42cad65..65a3e764b 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -15,19 +15,25 @@ 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' - ); - `); + const typeExists = await queryRunner.query( + `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`, + ); + + if (!typeExists.length) { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + } await queryRunner.query(` - CREATE TABLE freight.invoices ( + CREATE TABLE IF NOT EXISTS freight.invoices ( id uuid NOT NULL DEFAULT uuid_generate_v4(), invoice_number varchar(64) NOT NULL, company_id uuid NOT NULL, @@ -96,6 +102,8 @@ export class CreateInvoices1821000000002 implements MigrationInterface { 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;`); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.invoices_status_enum;`, + ); } } diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts new file mode 100644 index 000000000..70b0832f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.booking_container_allocations table — container-to-vehicle + * allocation mapping for flexible routing of containers across available vehicles. + */ +export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface { + name = 'CreateBookingContainerAllocations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.booking_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.booking_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts new file mode 100644 index 000000000..c57a43aaa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface { + name = 'AddGrnNumberToWarehouseInventory1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts new file mode 100644 index 000000000..55239c13f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Extend `freight.invoices` into the billing record of record for every source + * (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be + * centralized onto it instead of the parallel `warehouse_fee_invoices` table. + * + * Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`), + * a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID` + * statuses the warehouse flow uses. + * + * Matches billing/entities/invoice.entity.ts. All columns are additive with + * defaults, so existing booking/demurrage rows are unaffected. + */ +export class ExtendInvoicesForPartialPayment1828000000000 + implements MigrationInterface +{ + name = "ExtendInvoicesForPartialPayment1828000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long + // as the value is not referenced in the same transaction (it is not here). + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`, + ); + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`, + ); + + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + `); + + // Backfill existing rows: subtotal mirrors the total (no tax was modeled), + // the outstanding balance is the full total for unpaid invoices. + await queryRunner.query(` + UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount; + `); + + // Already-settled invoices: fully paid, zero balance, stamped from updated_at. + await queryRunner.query(` + UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = updated_at + WHERE status = 'PAID'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS payments, + DROP COLUMN IF EXISTS paid_at, + DROP COLUMN IF EXISTS balance_amount, + DROP COLUMN IF EXISTS paid_amount, + DROP COLUMN IF EXISTS tax_amount, + DROP COLUMN IF EXISTS subtotal_amount; + `); + // Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are + // left on freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..dd246cb7d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -0,0 +1,222 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold warehouse fee invoices into the central billing system. + * + * Warehouse fee invoices are no longer a standalone aggregate: each becomes a + * global `freight.invoices` row (`source = 'warehouse'`, `source_id = + * inventory_id`) with its items as `freight.invoice_lines`. The warehouse + * service is now a thin layer over `BillingService`. This migration backfills the + * existing rows (preserving ids, numbers, status, amounts and payment history), + * then drops the two legacy tables. + * + * Rows that cannot be billed centrally — no company to bill (`company_id` / + * `company_profile_id` underivable from the customer or the booking) — are not + * migrated; they could never have been charged through the gateway and are + * dropped with the table. + */ +export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { + name = 'CentralizeWarehouseInvoices1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Invoice headers. Keep the same id so items still link, and so any + // external reference to the invoice id stays valid. + await queryRunner.query(` + INSERT INTO freight.invoices ( + id, invoice_number, company_id, company_profile_id, + subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, status, source, source_id, type, + issued_at, paid_at, payments, payment_id, due_at, + created_at, updated_at, deleted_at + ) + SELECT + fee.id, + fee.invoice_number, + COALESCE(fee.customer_id, b.company_id), + COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ), + fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, + fee.currency, + fee.status::freight.invoices_status_enum, + 'warehouse', + fee.inventory_id, + fee.invoice_type, + fee.issued_at, + fee.paid_at, + COALESCE(fee.payments, '[]'::jsonb), + NULL, + COALESCE(fee.due_date, fee.issued_at, fee.created_at), + fee.created_at, fee.updated_at, fee.deleted_at + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.bookings b ON b.id = fee.booking_id + WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL + AND COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ) IS NOT NULL + ON CONFLICT (id) DO NOTHING; + `); + + // 2. Invoice lines — only for items whose parent invoice migrated. Warehouse + // fee fields (fee_rule_id / chargeable_days / free_days) move into the + // line's jsonb metadata. + await queryRunner.query(` + INSERT INTO freight.invoice_lines ( + id, invoice_id, charge_type, description, quantity, unit_rate, amount, + currency, metadata, created_at, updated_at, deleted_at + ) + SELECT + item.id, + item.invoice_id, + item.fee_type, + item.description, + item.quantity, + item.unit_rate, + item.amount, + item.currency, + jsonb_build_object( + 'feeRuleId', item.fee_rule_id, + 'chargeableDays', item.chargeable_days, + 'freeDays', item.free_days + ), + item.created_at, item.updated_at, item.deleted_at + FROM freight.warehouse_fee_invoice_items item + JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // 3. Drop the legacy tables (items first — FK to invoices). + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate the legacy tables … + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_number varchar(40) NOT NULL, + booking_id uuid, + customer_id uuid, + inventory_id uuid NOT NULL, + facility_id uuid, + warehouse_id uuid, + yard_id uuid, + zone_id uuid, + invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, + tax_amount numeric(14,2) NOT NULL DEFAULT 0, + total_amount numeric(14,2) NOT NULL DEFAULT 0, + paid_amount numeric(14,2) NOT NULL DEFAULT 0, + balance_amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + period_start timestamptz, + period_end timestamptz, + issued_at timestamptz, + due_date timestamptz, + paid_at timestamptz, + cancelled_at timestamptz, + payments jsonb NOT NULL DEFAULT '[]', + notes text, + CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), + CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, + ); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_id uuid NOT NULL, + fee_rule_id uuid, + fee_type varchar(32) NOT NULL, + description varchar(255) NOT NULL, + quantity numeric(12,2) NOT NULL DEFAULT 1, + unit_rate numeric(14,2) NOT NULL DEFAULT 0, + amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + chargeable_days int, + free_days int, + CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), + CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" + FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, + ); + + // … then copy the warehouse-source invoices back, deriving the typed FKs and + // period from the linked inventory item. + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoices ( + id, created_at, updated_at, deleted_at, invoice_number, + booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, + invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes + ) + SELECT + i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, + inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, + i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, + i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, + CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, + i.payments, NULL + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoice_items ( + id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, + description, quantity, unit_rate, amount, currency, chargeable_days, free_days + ) + SELECT + l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, + NULLIF(l.metadata->>'feeRuleId', '')::uuid, + l.charge_type, + COALESCE(l.description, ''), + l.quantity, l.unit_rate, l.amount, l.currency, + NULLIF(l.metadata->>'chargeableDays', '')::int, + NULLIF(l.metadata->>'freeDays', '')::int + FROM freight.invoice_lines l + JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // Remove the migrated rows from the central tables. + await queryRunner.query(` + DELETE FROM freight.invoice_lines + WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); + `); + await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts new file mode 100644 index 000000000..e4b353b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the `EXPIRED` invoice status. An invoice expires when its source's pay + * window closes before settlement (e.g. a booking whose `paymentDeadline` + * lapses) — driven event-style from the domain via `BillingService.expirePayable`, + * which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out + * of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and + * `OVERDUE` (still payable). + * + * Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and + * not referenced in this same transaction, so it is PG 12+ safe. + */ +export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface { + name = "AddExpiredInvoiceStatus1830000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`, + ); + } + + public async down(): Promise { + // Postgres cannot drop individual enum values; EXPIRED is left on + // freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts new file mode 100644 index 000000000..b91e88633 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.first_mile_container_allocations table — tracks + * container allocations per first-mile shipment with optional vehicle assignment. + */ +export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.first_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'first_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { name: 'container_type', type: 'text', isNullable: false }, + { + name: 'quantity', + type: 'int', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['first_mile_id'], + referencedTableName: 'freight.first_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.first_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const fuelPurchasesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`, + ); + + if (!fuelPurchasesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`, + ); + } + + const fuelConsumptionExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`, + ); + + if (!fuelConsumptionExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts new file mode 100644 index 000000000..261e16099 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add paid column to first_mile and last_mile tables to track invoice payment status. + */ +export class AddPaidToFirstAndLastMile1860000000000 + implements MigrationInterface +{ + name = "AddPaidToFirstAndLastMile1860000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + DROP COLUMN IF EXISTS paid; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS paid; + `); + } +} 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 e954b7e1b..7da5e2d66 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @@ -7,6 +8,7 @@ import { BillingService } from "./billing.service"; @ApiTags("billing") @Controller("billing") @FreightAdmin() +@ApiBearerAuth() export class BillingController { constructor(private readonly billingService: BillingService) { } @@ -21,4 +23,26 @@ export class BillingController { findById(@Param("id", ParseUUIDPipe) id: string) { return this.billingService.findById(id); } + + @Get("invoices/:id/document") + @ApiOperation({ summary: "Download the sealed invoice PDF" }) + async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.document(id); + sendPdf(res, filename, buffer); + } + + @Get("invoices/:id/receipt") + @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) + async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.receipt(id); + sendPdf(res, filename, buffer); + } +} + +/** Stream a generated PDF as a file download. */ +export function sendPdf(res: Response, filename: string, buffer: Buffer): void { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + res.send(buffer); } 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 551fae6bf..771156fd3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -3,7 +3,9 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; +import { PaymentController } from "./payment.controller"; import { BillingService } from "./billing.service"; +import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; @@ -16,8 +18,9 @@ import { CompaniesModule } from "../companies/companies.module"; TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), CompaniesModule, + DocumentsModule, ], - controllers: [BillingController, PortalBillingController], + controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) 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 index 0e6d97de0..4df2a0eb3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); }); @@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => { 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(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); @@ -115,12 +116,14 @@ describe("BillingService.generateInvoice", () => { }); describe("BillingService.markInvoiceAsPaid", () => { - it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, }; const mg = { findOne: jest.fn().mockResolvedValue(open), @@ -134,6 +137,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -141,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => { expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + { + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + paidAt: expect.any(Date), + paidAmount: 1500, + balanceAmount: 0, + payments: [ + { + amount: 1500, + method: "GATEWAY", + reference: "pay-1", + paidAt: expect.any(String), + metadata: null, + }, + ], + }, ); expect(events.emit).toHaveBeenCalledWith( "booking.invoice.paid", @@ -171,6 +190,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -180,71 +200,99 @@ describe("BillingService.markInvoiceAsPaid", () => { }); }); -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", - }; +describe("BillingService.recordPayment", () => { + function serviceFor(invoice: Record | null) { const mg = { - findOne: jest.fn().mockResolvedValue(open), + findOne: jest.fn().mockResolvedValue(invoice), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); + const dataSource = { + manager: mg, + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)), + }; const service = new BillingService( - { manager: mg } as never, + dataSource as never, {} as never, {} as never, events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); + return { service, mg, events }; + } - const settled = await service.settlePayable( - Freight.InvoiceSource.Booking, - "booking-1", - "pay-1", - mg as never, - ); + const openInvoice = (overrides: Record = {}) => ({ + id: "inv-1", + status: Freight.InvoiceStatus.Issued, + source: "warehouse", + sourceId: "inv-item-1", + totalAmount: 1000, + paidAmount: 0, + balanceAmount: 1000, + payments: [], + paidAt: null, + ...overrides, + }); - expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => { + const { service, mg, events } = serviceFor(openInvoice()); + + const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" }); + + expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid); + expect(updated.paidAmount).toBe(400); + expect(updated.balanceAmount).toBe(600); + expect(updated.payments).toHaveLength(1); expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + expect.objectContaining({ + status: Freight.InvoiceStatus.PartiallyPaid, + paidAmount: 400, + balanceAmount: 600, + }), ); - 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(); }); + + it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { + const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 })); + + const updated = await service.recordPayment("inv-1", { amount: 600 }); + + expect(updated.status).toBe(Freight.InvoiceStatus.Paid); + expect(updated.balanceAmount).toBe(0); + expect(updated.paidAt).toBeInstanceOf(Date); + expect(mg.update).toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith( + "warehouse.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), + ); + }); + + it("rejects a non-positive amount", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects a payment that exceeds the outstanding balance", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect( + service.recordPayment("inv-1", { amount: 1500 }), + ).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects payment against a cancelled invoice", async () => { + const { service, mg } = serviceFor( + openInvoice({ status: Freight.InvoiceStatus.Cancelled }), + ); + await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); }); 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 fa5681b06..278d4cca9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,15 +1,28 @@ -import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { EventEmitter2 } from "@nestjs/event-emitter"; import { Freight, PaymentReferenceType } from "@edr/types"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice } from "./entities/invoice.entity"; -import { InvoiceLine } from "./entities/invoice-line.entity"; -import { InvoiceRepository } from "./invoice.repository"; -import { InvoiceLineRepository } from "./invoice-line.repository"; +import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto } from "../payment/payments.dto"; -import { CompaniesService } from "../companies/companies.service"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "./documents/invoice-document.service"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ export interface PayInvoiceOptions { @@ -20,13 +33,25 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** A single manual/offline settlement to record against an invoice. */ +export interface RecordPaymentInput { + /** Amount settled by this payment; must be > 0. */ + amount: number; + method?: string | null; + reference?: string | null; + /** When the settlement occurred; defaults to now. */ + paidAt?: Date; + metadata?: Record | null; +} + /** Default invoice payment-term window, in days, used to compute `dueAt`. */ const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ - Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; @@ -56,7 +81,11 @@ export interface GenerateInvoiceInput { companyProfileId: string; lines: InvoiceLineInput[]; currency?: string; - /** Explicit total; defaults to the sum of line amounts. */ + /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ + subtotalAmount?: number; + /** Tax applied on top of the subtotal; defaults to 0. */ + taxAmount?: number; + /** Explicit total; defaults to `subtotalAmount + taxAmount`. */ totalAmount?: number; /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ dueAt?: Date; @@ -95,6 +124,7 @@ export class BillingService { @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, private readonly companies: CompaniesService, + private readonly invoiceDocuments: InvoiceDocumentService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -115,6 +145,89 @@ export class BillingService { return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; } + // ── Documents (central PDF) ────────────────────────────────────────────────── + + /** Sealed PDF invoice for any source, rendered by the shared document service. */ + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); + } + + /** Sealed PDF receipt; available once any payment has been recorded. */ + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); + } + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); + } + + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ + private toDocumentModel( + invoice: Invoice & { lines: InvoiceLine[] }, + kind: "INVOICE" | "RECEIPT", + ): InvoiceDocumentModel { + const title = invoice.source + ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) + : "EDR"; + const totals: InvoiceDocumentModel["totals"] = [ + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + ]; + if (Number(invoice.taxAmount) > 0) { + totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); + } + totals.push({ + label: "Total", + amount: Number(invoice.totalAmount), + grand: true, + }); + totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); + totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + + return { + kind, + title, + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ], + categoryHeader: "Charge type", + lines: invoice.lines.map((l) => ({ + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + })), + totals, + }; + } + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── /** Resolve the customer's company id from their IAM user id (null if none). */ @@ -127,19 +240,33 @@ export class BillingService { } } - /** Every invoice billed to a company, newest first, with billing relations. */ - findByCompany(companyId: string): Promise { + /** + * Every invoice billed to a company, newest first, with billing relations. + * Optionally narrow to a single source record (e.g. a booking's invoices) via + * `{ source, sourceId }`. + */ + findByCompany( + companyId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { return this.invoices.findAll({ - where: { companyId }, + where: { + companyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, 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 { + async findForUser( + userId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId) : []; + return companyId ? this.findByCompany(companyId, filter) : []; } /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ @@ -157,36 +284,43 @@ export class BillingService { /** * 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}). + * ownership, then charges the invoice directly by ID (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, - ); + await this.findByIdForUser(id, userId); + return this.payInvoice(id, opts); + } + + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ + async documentForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.document(id); + } + + /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ + async receiptForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.receipt(id); } // ── 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")}`; + /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ + private nextInvoiceNumber(mg: EntityManager): Promise { + return nextDailyInvoiceNumber(mg, { + table: "freight.invoices", + code: "INV", + }); } /** @@ -204,6 +338,7 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { + console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } @@ -230,8 +365,11 @@ export class BillingService { }; }); - const totalAmount = - input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + const subtotalAmount = + input.subtotalAmount ?? + lines.reduce((sum, l) => sum + Number(l.amount), 0); + const taxAmount = input.taxAmount ?? 0; + const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? @@ -250,7 +388,12 @@ export class BillingService { type: input.type, companyId: input.companyId, companyProfileId: input.companyProfileId, - totalAmount, + subtotalAmount: round2(subtotalAmount), + taxAmount: round2(taxAmount), + totalAmount: round2(totalAmount), + paidAmount: 0, + balanceAmount: round2(totalAmount), + payments: [], currency, status, issuedAt: issued ? new Date() : null, @@ -274,28 +417,182 @@ export class BillingService { // ── 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. + * Run `fn` inside a transaction and only emit its returned domain event + * after commit. When the caller passes their own `manager`, they own commit + * timing — `fn`'s event fires inline as soon as it resolves (the outer + * transaction may still roll back afterwards; this is the caller's + * documented tradeoff). When no `manager` is given, this opens its own + * transaction and defers the emit until after that transaction commits, so + * listeners (e.g. booking advancement) can never observe an invoice change + * that then rolls back. + */ + private async runTransition( + manager: EntityManager | undefined, + fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>, + ): Promise { + if (manager) { + const { result, emit } = await fn(manager); + emit?.(); + return result; + } + let pending: (() => void) | undefined; + const result = await this.dataSource.transaction(async (mg) => { + const out = await fn(mg); + pending = out.emit; + return out.result; + }); + pending?.(); + return result; + } + + /** + * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, + * append the settlement to the `payments` ledger, 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; otherwise locks the row for update and + * emits only after commit (see {@link runTransition}). */ async markInvoiceAsPaid( invoiceId: string, paymentId: string | null = null, manager?: EntityManager, + settlement: { providerTxnId?: string; paidAt?: Date } = {}, ): Promise { - return this.transition( - invoiceId, - Freight.InvoiceStatus.Paid, - "paid", - { paymentId: paymentId ?? undefined }, - manager, - ); + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + return { result: invoice }; + } + + const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date(); + const settledAmount = round2( + Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0), + ); + const entry: InvoicePayment = { + amount: settledAmount, + method: "GATEWAY", + reference: settlement.providerTxnId ?? paymentId ?? null, + paidAt: paidAt.toISOString(), + metadata: null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + status: Freight.InvoiceStatus.Paid, + paymentId, + paidAt, + paidAmount: invoice.totalAmount, + balanceAmount: 0, + payments, + }; + await mg.update(Invoice, { id: invoiceId }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent("paid", updated), + }; + }); + } + + /** + * Record a (possibly partial) settlement against an invoice and sync its + * status. Appends to the `payments` ledger, recomputes `paidAmount` / + * `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the + * balance reaches zero — PAID, stamping `paidAt` and emitting + * `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash + * at the warehouse counter); gateway settlement goes through + * {@link markInvoiceAsPaid}. + * + * Throws when the invoice is missing, cancelled, refunded, already fully + * paid, `amount` is not positive, or `amount` exceeds the outstanding + * balance. Pass `manager` to enlist in a caller's transaction; otherwise + * locks the row for update and emits only after commit (see + * {@link runTransition}). + */ + async recordPayment( + invoiceId: string, + input: RecordPaymentInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount > 0)) { + throw new BadRequestException( + "Payment amount must be greater than zero.", + ); + } + + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + if (round2(input.amount) > Number(invoice.balanceAmount)) { + throw new BadRequestException( + `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, + ); + } + + const at = input.paidAt ?? new Date(); + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; + + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + }; + await mg.update(Invoice, { id: invoice.id }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: fullyPaid + ? () => this.emitInvoiceEvent("paid", updated) + : undefined, + }; + }); } /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. - * No-op when already refunded. + * No-op when already refunded. Throws when the invoice has no recorded + * payment (nothing to refund). */ async markInvoiceAsRefunded( invoiceId: string, @@ -307,12 +604,20 @@ export class BillingService { "refunded", {}, manager, + (invoice) => { + if (!(Number(invoice.paidAmount) > 0)) { + throw new BadRequestException( + "Cannot refund an invoice with no recorded payment.", + ); + } + }, ); } /** * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. - * No-op when already cancelled. + * No-op when already cancelled. Throws when the invoice has payments + * recorded against it (refund it instead). */ async cancelInvoice( invoiceId: string, @@ -324,16 +629,23 @@ export class BillingService { "cancelled", {}, manager, + (invoice) => { + if (Number(invoice.paidAmount) > 0) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + }, ); } /** * 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. + * `${source}.invoice.`. No-op (returns the invoice, skipping `guard`) + * when it is already in the target status. Throws when the invoice does not + * exist or `guard` rejects the current state. Pass `manager` to enlist in a + * caller's transaction; otherwise locks the row for update and emits only + * after commit (see {@link runTransition}). */ private async transition( invoiceId: string, @@ -341,17 +653,27 @@ export class BillingService { event: string, extra: { paymentId?: string }, manager?: EntityManager, + guard?: (invoice: Invoice) => void, ): 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; + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === status) return { result: invoice }; + guard?.(invoice); - await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); - const updated = { ...invoice, ...extra, status } as Invoice; - this.emitInvoiceEvent(event, updated); - return updated; + const updated = { ...invoice, ...extra, status } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent(event, updated), + }; + }); } /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ @@ -375,16 +697,17 @@ export class BillingService { // ── 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. + * The invoice a source record already has open, or null if it needs a new + * one. This is the idempotency check every `ensureInvoiceFor*` (booking, + * first-mile, last-mile) runs before generating — it must see DRAFT + * invoices too, not just issued ones, otherwise a source that already has + * an unissued draft gets a second, duplicate invoice minted alongside it + * instead of that draft being reused and then issued. * * 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. + * invoice is currently open. Returns the most recent matching draft-or-open + * (unpaid, non-cancelled) invoice. */ findPayable( source: Freight.InvoiceSource, @@ -395,7 +718,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -403,74 +726,130 @@ export class BillingService { } /** - * 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. + * 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. */ - async settlePayable( + findInvoice( source: Freight.InvoiceSource, sourceId: string, - paymentId: string | null, - manager?: EntityManager, + type?: string, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: In(OPEN_STATUSES) }, + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + ...(type ? { type } : {}), + }, 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. + * Expire a source's currently-open invoice (its pay window closed before + * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice + * and transitions it to EXPIRED — a terminal, non-payable status (kept out of + * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice + * (already paid/cancelled/expired). * - * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) - * to enlist in its DB transaction. + * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in + * the batch engine) to enlist in its DB transaction. */ - async refundPayable( + async expirePayable( source: Freight.InvoiceSource, sourceId: string, + type?: string, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; - return this.markInvoiceAsRefunded(invoice.id, mg); + return this.transition( + invoice.id, + Freight.InvoiceStatus.Expired, + "expired", + {}, + mg, + ); + } + + /** + * Sync a source's open invoice `dueAt` to its real pay-window deadline. The + * booking invoice is generated before the pay window opens (at booking + * creation/approval), so its printed due date is refreshed when the batch engine + * sets `paymentDeadline`. No-op when the source has no open invoice. + */ + async syncPayableDueDate( + source: Freight.InvoiceSource, + sourceId: string, + dueAt: Date, + type?: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { dueAt }); + } + + /** + * Force an invoice to `status`, including issuing a still-DRAFT invoice + * (stamping `issuedAt`) — unlike the other transitions here, this is a + * blunt admin/workflow override, not a settlement. No-op when the invoice + * is missing or already terminal (paid/cancelled/refunded/expired). + */ + async updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, + }); + if (!invoice) return; + await mg.update( + Invoice, + { id: invoice.id }, + { status, issuedAt: invoice.issuedAt ?? new Date() }, + ); } // ── 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. + * Charge an 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 invoice by ID, + * opens an intent for `invoice.balanceAmount` (so partial payments are honored), + * 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. + * never fire before the link exists. Throws when the invoice is not found or + * not in an open/payable status. */ async payInvoice( - source: Freight.InvoiceSource, - sourceId: string, + invoiceId: string, opts: { method?: string; platform?: "web" | "mobile"; @@ -479,22 +858,26 @@ export class BillingService { failureUrl?: string; } = {}, ): Promise { - const invoice = await this.findPayable(source, sourceId); + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + }); if (!invoice) { - throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + throw new NotFoundException( + `Invoice ${invoiceId} not found or not in a payable status`, + ); } const result = await this.payment.initiate({ - referenceId: sourceId, + referenceId: invoice.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, - orderRef:Date.now().toString(), - amountMinor: Math.round(Number(invoice.totalAmount)), + // Freight payments settle under the generic SHIPMENT reference — how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, + orderRef: invoice.invoiceNumber, + amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -529,8 +912,8 @@ export class BillingService { */ async settleByPaymentId( paymentId: string, - _providerTxnId?: string, - _paidAt?: Date, + providerTxnId?: string, + paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { paymentId, status: In(OPEN_STATUSES) }, @@ -538,6 +921,9 @@ export class BillingService { }); if (!invoice) return null; - return this.markInvoiceAsPaid(invoice.id, paymentId); + return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { + providerTxnId, + paidAt, + }); } } diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts new file mode 100644 index 000000000..c320a5d44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; + +import { InvoiceDocumentService } from "./invoice-document.service"; +import { PdfRenderService } from "./pdf-render.service"; + +/** + * Standalone document infrastructure — generic HTML→PDF plus the shared + * invoice/receipt renderer. Has no domain dependencies, so any module (billing, + * warehouses, …) can import it to print invoices without coupling to the + * billing payment graph. + */ +@Module({ + providers: [PdfRenderService, InvoiceDocumentService], + exports: [PdfRenderService, InvoiceDocumentService], +}) +export class DocumentsModule {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts new file mode 100644 index 000000000..a07087f8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from "@nestjs/common"; + +import { PdfRenderService } from "./pdf-render.service"; + +export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; + +/** One billed line on the document (charge type / fee type agnostic). */ +export interface InvoiceDocumentLine { + description: string | null; + /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */ + category?: string | null; + quantity?: number | null; + unitRate?: number | null; + amount?: number | null; + currency?: string | null; +} + +/** A labelled total row in the totals box; mark `grand` for the headline total. */ +export interface InvoiceDocumentTotal { + label: string; + amount: number; + grand?: boolean; +} + +/** + * Source-agnostic description of a printable invoice/receipt. Each billing + * source maps its own entity onto this shape; the renderer owns the layout so + * every EDR invoice document looks identical regardless of source. + */ +export interface InvoiceDocumentModel { + kind: InvoiceDocumentKind; + /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */ + title: string; + documentNumber: string; + issuedAt?: Date | string | null; + status: string; + currency: string; + /** Free-form summary grid (label/value pairs). */ + summary: Array<{ label: string; value: string | null }>; + /** Header for the line-item category column; column hidden when omitted. */ + categoryHeader?: string; + lines: InvoiceDocumentLine[]; + totals: InvoiceDocumentTotal[]; + /** Override the round seal text; defaults from kind/status. */ + sealText?: string; +} + +/** + * Central invoice/receipt PDF renderer shared by every billing source. Turns a + * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it + * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in + * `WarehouseInvoiceService`; it now serves all invoices. + */ +@Injectable() +export class InvoiceDocumentService { + constructor(private readonly pdf: PdfRenderService) {} + + async render( + model: InvoiceDocumentModel, + ): Promise<{ filename: string; buffer: Buffer }> { + const html = this.buildHtml(model); + const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; + return { + filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), + }; + } + + buildHtml(model: InvoiceDocumentModel): string { + const esc = (value: unknown) => + String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const money = (amount: unknown, currency = model.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const showCategory = Boolean(model.categoryHeader); + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + + const summaryRows = model.summary + .map((row) => `
${esc(row.label)}${esc(row.value)}
`) + .join(""); + + const itemRows = model.lines + .map( + (item) => ` + ${esc(item.description)} + ${showCategory ? `${esc((item.category ?? "").replace(/_/g, " "))}` : ""} + ${esc(item.quantity ?? 0)} + ${esc(money(item.unitRate, item.currency ?? model.currency))} + ${esc(money(item.amount, item.currency ?? model.currency))} + `, + ) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount))}
`, + ) + .join(""); + + return ` + + + + ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+ + + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+
${totalRows}
+ +
+ +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -0,0 +1,160 @@ +import { existsSync } from "fs"; + +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +export interface PdfRenderOptions { + /** Label used in logs to identify the document kind. */ + label?: string; + /** + * Degraded renderer used when Chromium is unavailable. Receives the + * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` + * header). When omitted, a generic single-page fallback is produced. + */ + fallback?: (preparedHtml: string) => Buffer; +} + +/** + * Generic HTML → PDF renderer shared by every document producer (invoices, + * receipts, warehouse release orders). Renders via headless Chromium when + * available and degrades to a caller-supplied (or generic) hand-built PDF + * otherwise. This is pure infrastructure — it knows nothing about invoices. + */ +@Injectable() +export class PdfRenderService { + private readonly logger = new Logger(PdfRenderService.name); + + async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise { + const label = opts.label ?? "document"; + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import("puppeteer"); + const launchOptions: import("puppeteer").LaunchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); + await page.emulateMediaType("print"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`); + } + this.logger.log( + `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`, + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes("edr-pdf-print-fix")) return html; + if (html.includes("")) { + return html.replace("", `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + ]; + return candidates.find((path) => existsSync(path)); + } + + isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + + /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */ + private genericFallbackPdf(html: string): Buffer { + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + + const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40); + const stream = + "BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" + + lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") + + "ET"; + + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n"; + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); + } +} 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 61bc9c16b..23c332f80 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 @@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; +/** A single recorded settlement against an invoice (payment ledger entry). */ +export interface InvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + /** ISO timestamp of when the settlement was recorded. */ + paidAt: string; + metadata?: Record | null; +} + @Entity({ schema: "freight", name: "invoices" }) @Index(["companyId"]) @Index(["companyProfileId"]) @@ -28,9 +38,24 @@ export class Invoice extends BaseEntity { @JoinColumn({ name: "company_profile_id" }) companyProfile?: CompanyProfile; + /** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */ + @Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) totalAmount!: number; + /** Cumulative amount settled so far (supports partial payment). */ + @Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + /** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */ + @Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -62,6 +87,14 @@ export class Invoice extends BaseEntity { @Column({ name: "issued_at", type: "timestamptz", nullable: true }) issuedAt?: Date | null; + /** Set when the invoice is fully settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Ledger of individual settlements (manual or gateway), newest last. */ + @Column({ name: "payments", type: "jsonb", default: () => "'[]'" }) + payments!: InvoicePayment[]; + /** The ID of the payment that generated this invoice. */ @Column({ name: "payment_id", type: "uuid", nullable: true }) paymentId?: string | null; diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d3e6a208f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -0,0 +1,51 @@ +/** + * Shared per-day sequential invoice numbering, used by every billing source + * (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the + * `MAX(seq)+1` allocation live in one place instead of being copy-pasted per + * service. + * + * Produces `-YYYYMMDD-00001`: the sequence is the max existing suffix for + * the day + 1. Run inside the caller's transaction (pass that transaction's + * manager) so concurrent generation within a transaction stays consistent. + */ + +/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ +export interface SqlRunner { + query(sql: string, params?: unknown[]): Promise; +} + +export interface InvoiceNumberOptions { + /** Schema-qualified table to scan, e.g. `freight.invoices`. */ + table: string; + /** Document code prefix, e.g. `FRT` or `WHF`. */ + code: string; + /** Column holding the number; defaults to `invoice_number`. */ + column?: string; + /** Clock injection point (tests); defaults to now. */ + now?: Date; +} + +export async function nextDailyInvoiceNumber( + runner: SqlRunner, + opts: InvoiceNumberOptions, +): Promise { + const now = opts.now ?? new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `${opts.code}-${ymd}-`; + const column = opts.column ?? "invoice_number"; + + // Serialize concurrent allocation for this exact day+code prefix so two + // simultaneous transactions can't both read the same MAX(seq) and mint a + // duplicate number. Session-scoped to the caller's transaction — released + // automatically on commit/rollback. Different prefixes hash to different + // keys and never contend with each other. + await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]); + + const rows = (await runner.query( + `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq + FROM ${opts.table} WHERE ${column} LIKE $1`, + [`${prefix}%`], + )) as Array<{ seq: number | string }>; + const next = Number(rows[0]?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -0,0 +1,36 @@ +/** + * Shared payment/settlement math for invoices. Both the global + * `BillingService.recordPayment` and the warehouse fee invoice flow apply a + * payment the same way — accumulate `paidAmount`, derive the outstanding + * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping + * it here means the two flows can never drift on rounding or the + * partial-vs-full threshold. + */ + +/** Round to 2 decimals, avoiding binary float drift. */ +export const round2 = (n: number): number => Math.round(n * 100) / 100; + +export interface SettlementResult { + /** New cumulative amount paid. */ + paidAmount: number; + /** Remaining balance (0 once fully paid). */ + balanceAmount: number; + /** True once the balance reaches zero. */ + fullyPaid: boolean; +} + +/** + * Apply a single payment of `amount` to an invoice with `totalAmount` already + * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and + * the invoice being in a payable state. + */ +export function applySettlement( + totalAmount: number, + currentPaid: number, + amount: number, +): SettlementResult { + const total = Number(totalAmount); + const paidAmount = round2(Number(currentPaid) + Number(amount)); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts similarity index 83% rename from apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts rename to apps/edr-freight-api/src/modules/billing/payment.controller.ts index ae01ebc36..543c84201 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts @@ -16,9 +16,8 @@ import { } 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 { BillingService } from "./billing.service"; import { InitiatePaymentDto, InitiateResponseDto, @@ -27,25 +26,24 @@ import { } 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. + * Central payment entrypoints. Domain-agnostic — the caller supplies an + * invoice ID and the billing service resolves the amount and drives the + * gateway. The domain never talks to the payment service directly. * Routes are unchanged (`/payments/*`) so the portal is unaffected. */ @ApiTags("Payment") @Controller("payments") -export class BookingPaymentController { +export class PaymentController { 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.", + summary: "Initiate payment for an invoice", + description: "Charges the invoice through the payment gateway.", }) @ApiOkResponse({ type: InitiateResponseDto }) initiate(@Body() dto: InitiatePaymentDto): Promise { - return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + return this.billing.payInvoice(dto.invoiceId, { method: dto.method, platform: dto.platform, payerAccount: dto.payerAccount, @@ -59,23 +57,23 @@ export class BookingPaymentController { @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.", + "Charges the 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: "invoiceId", 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("invoiceId") invoiceId: string, @Query("method") method: PaymentMethodTypeEnum, @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { - if (!bookingId) { + if (!invoiceId) { return res .status(HttpStatus.BAD_REQUEST) .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + .send(this.buildErrorHtml("Missing required query parameter: invoiceId")); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { return res @@ -86,8 +84,7 @@ export class BookingPaymentController { try { const result = await this.billing.payInvoice( - Freight.InvoiceSource.Booking, - bookingId, + invoiceId, { method, platform }, ); const url = 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 index 5a007c320..94e917754 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -5,14 +5,18 @@ import { Param, ParseUUIDPipe, Post, + Query, + Res, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import { type AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; +import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; import { PayInvoiceDto } from "./dto/pay-invoice.dto"; @@ -29,8 +33,15 @@ export class PortalBillingController { @Get("my-invoices") @ApiOperation({ summary: "List the signed-in customer's invoices" }) - findMine(@CurrentUser() user: AuthUserPayload) { - return this.billingService.findForUser(resolveAuthUserId(user)); + findMine( + @CurrentUser() user: AuthUserPayload, + @Query("source") source?: string, + @Query("sourceId") sourceId?: string, + ) { + return this.billingService.findForUser(resolveAuthUserId(user), { + source, + sourceId, + }); } @Get("my-invoices/:id") @@ -42,6 +53,34 @@ export class PortalBillingController { return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); } + @Get("my-invoices/:id/document") + @ApiOperation({ summary: "Download one of the customer's invoice PDFs" }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.documentForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Get("my-invoices/:id/receipt") + @ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" }) + async receipt( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.receiptForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + @Post("my-invoices/:id/pay") @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) pay( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts new file mode 100644 index 000000000..cfb9887c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingsService } from './bookings.service'; +import { AllocateContainersDto } from './dto/allocate-containers.dto'; + +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() +export class BookingAllocationController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post(':bookingId/allocate-containers') + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AllocateContainersDto, + ) { + return this.bookingsService.allocateContainers(bookingId, dto.allocations); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 47338f196..3bfb838b4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,20 +1,26 @@ -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource, EntityManager } 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'; +} 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 { @@ -23,6 +29,12 @@ interface StoredPricingBreakdown { currency?: string; } +export interface InvoiceOptions { + dueDate?: Date; + invoiceType?: string; + invoiceStatus?: Freight.InvoiceStatus; +} + /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; @@ -52,32 +64,28 @@ export class BookingInvoiceService { * 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. + * Throws `BadRequestException` 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 { + async ensureInvoiceForBooking( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, booking.id, - Freight.InvoiceType.Prepaid, + "PREPAID", ); if (existing) return existing; if (!booking.companyId) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + throw new BadRequestException( + `Cannot generate 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; - } + const input = this.buildInput(booking, invoiceOptions); return this.billing.generateInvoice(input); } @@ -87,10 +95,10 @@ export class BookingInvoiceService { * 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') + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { switch (payload.type) { - case Freight.InvoiceType.Prepaid: + case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); break; default: @@ -100,6 +108,14 @@ export class BookingInvoiceService { } } + updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + return this.billing.updateStatus(invoiceId, status, manager); + } + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -114,16 +130,18 @@ export class BookingInvoiceService { 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.`); + this.logger.warn( + `Cannot advance unknown booking ${bookingId} on payment.`, + ); return; } - if (booking.paymentStatus === 'PAID') return; + if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( Booking, { id: bookingId }, - { paymentStatus: 'PAID', status: 'PAID' }, + { paymentStatus: "PAID", status: "PAID" }, ); await this.firstMile.acceptBooking(bookingId); }); @@ -138,9 +156,13 @@ export class BookingInvoiceService { } /** 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'; + private buildInput( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): GenerateInvoiceInput { + const breakdown = (booking.pricingBreakdown ?? + {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ chargeType: l.code, @@ -155,10 +177,14 @@ export class BookingInvoiceService { // 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; + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + } lines.push({ - chargeType: 'FREIGHT', - description: 'Rail freight', + chargeType: "FREIGHT", + description: "Rail freight", quantity: 1, unitRate: amount, amount, @@ -166,7 +192,9 @@ export class BookingInvoiceService { }); } - const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0)); + 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 @@ -176,8 +204,8 @@ export class BookingInvoiceService { const delta = round2(Number(adjusted) - subtotal); if (delta !== 0) { lines.push({ - chargeType: 'ADJUSTMENT', - description: 'Staff price adjustment', + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", quantity: 1, unitRate: delta, amount: delta, @@ -190,12 +218,14 @@ export class BookingInvoiceService { return { source: Freight.InvoiceSource.Booking, sourceId: booking.id, - type: Freight.InvoiceType.Prepaid, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency, lines, totalAmount, + dueAt: invoiceOptions.dueDate, + type: invoiceOptions.invoiceType ?? "PREPAID", + status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, }; } } 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 deleted file mode 100644 index 1fbe34e1f..000000000 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 { BillingService } from '../billing/billing.service'; -import { PaymentMethodTypeEnum } from '../payment/payments.dto'; -export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } - -@Injectable() -export class BookingPaymentService { - constructor( - private readonly bookingsRepository: BookingsRepository, - 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 resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { - method: PaymentMethodTypeEnum.TELEBIRR, - platform: 'web', - }); - - const action = resp.clientAction as { type?: string; url?: string } | undefined; - return { - redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', - }; - } - - private async requireBooking(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); - if (!booking) throw new NotFoundException(`Booking ${id} not found`); - return booking; - } -} 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 2eed5100d..898f78cd5 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 @@ -4,8 +4,8 @@ import { Inject, Injectable, Logger, -} from '@nestjs/common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; @@ -15,7 +15,6 @@ 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'; @@ -29,16 +28,18 @@ import { BookingClearanceService } from '../contracts/booking-clearance.service' import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; + +import { Freight } from "@edr/types"; +import { BookingInvoiceService } from "./booking-invoice.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)) @@ -49,6 +50,8 @@ export class BookingTransitionService { private readonly bookingClearanceService: BookingClearanceService, @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, + private readonly invoiceService: BookingInvoiceService, + ) {} private isPhasedGeneralCustoms(booking: Booking): boolean { @@ -57,11 +60,11 @@ export class BookingTransitionService { async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); if (Number(booking.totalAmount) <= 0) { throw new BadRequestException( - 'Generate a price before submitting (POST /bookings/:id/generate-price)', + "Generate a price before submitting (POST /bookings/:id/generate-price)", ); } @@ -80,7 +83,8 @@ export class BookingTransitionService { totalAmount?: number; } | null; const unchanged = this.pricingService.pricesMatch(stored, computed); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); if (unchanged) { await this.pricingService.createPricingSnapshots( @@ -90,7 +94,7 @@ export class BookingTransitionService { ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, } as never); @@ -120,7 +124,7 @@ export class BookingTransitionService { currency: computed.currency, generatedAt: new Date().toISOString(), }, - status: 'PRICE_CHANGED_PENDING_CONFIRM', + status: "PRICE_CHANGED_PENDING_CONFIRM", } as never); const updatedBooking = await this.bookingsService.findById(bookingId); @@ -132,16 +136,17 @@ export class BookingTransitionService { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, - message: 'Price has changed since preview. Confirm to submit with the updated price.', + message: + "Price has changed since preview. Confirm to submit with the updated price.", }; } async confirmSubmit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]); if (Number(booking.totalAmount) <= 0) { - throw new BadRequestException('No price to confirm'); + throw new BadRequestException("No price to confirm"); } const computed = await this.pricingService.computePriceForBooking(booking); @@ -160,9 +165,10 @@ export class BookingTransitionService { computed.appliedModifiers, ); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, totalAmount: computed.totalAmount, pricingBreakdown: { @@ -184,7 +190,7 @@ export class BookingTransitionService { totalAmount: Number(finalBooking.totalAmount), currency: finalBooking.paymentCurrency, lineItems: computed.lineItems, - message: 'Booking submitted with confirmed price.', + message: "Booking submitted with confirmed price.", }; } @@ -194,17 +200,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); await this.bookingsRepository.createReviewNote( bookingId, note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CHANGES_REQUESTED', + status: "CHANGES_REQUESTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -214,7 +220,7 @@ export class BookingTransitionService { if ((booking.approvalSteps?.length ?? 0) > 0) return; await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); } @@ -228,14 +234,14 @@ export class BookingTransitionService { // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( - 'A contract validity (in days) is required to accept this booking.', + "A contract validity (in days) is required to accept this booking.", ); } @@ -245,12 +251,12 @@ export class BookingTransitionService { validUntil.setDate(validUntil.getDate() + validityDays); await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); const updated = await this.bookingsRepository.update(bookingId, { - status: 'PENDING_APPROVAL', + status: "PENDING_APPROVAL", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, @@ -266,17 +272,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -294,8 +300,8 @@ export class BookingTransitionService { let booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'PENDING_APPROVAL', - 'APPROVED_PENDING_SIGNATURE', + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", ]); if ((booking.approvalSteps?.length ?? 0) === 0) { @@ -307,14 +313,17 @@ export class BookingTransitionService { bookingId, stepId, ); - if (!step || step.status !== 'PENDING') { - throw new BadRequestException('Approval step not found or already actioned'); + if (!step || step.status !== "PENDING") { + throw new BadRequestException( + "Approval step not found or already actioned", + ); } - const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + const next = + await this.bookingsRepository.findNextPendingApprovalStep(bookingId); if (!next || next.id !== step.id) { throw new BadRequestException( - 'Approval steps must be completed in order', + "Approval steps must be completed in order", ); } @@ -326,29 +335,36 @@ export class BookingTransitionService { const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + throw new BadRequestException( + `Role ${requiredRole} is blocked for this step`, + ); } - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + "APPROVED", + ); const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; + if (requiredRole === "LINE_STAFF") { + updates.status = "APPROVED_PENDING_SIGNATURE"; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (requiredRole === "DIRECTOR") { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (requiredRole === "CEO") { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } - const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + const allDone = + await this.bookingsRepository.allApprovalStepsComplete(bookingId); if (allDone) { - updates.status = 'APPROVED'; + updates.status = "APPROVED"; } if (Object.keys(updates).length > 0) { @@ -370,90 +386,64 @@ export class BookingTransitionService { reason: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + assertBookingStatus(booking, [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + ]); const step = await this.bookingsRepository.findApprovalStepById( bookingId, stepId, ); - if (!step) throw new BadRequestException('Approval step not found'); + if (!step) throw new BadRequestException("Approval step not found"); await this.bookingsRepository.completeApprovalStep( step.id, actorId, - 'REJECTED', + "REJECTED", reason, ); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CONTRACT_READY']); + assertBookingStatus(booking, ["CONTRACT_READY"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SIGNED_CUSTOMER', + status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); return this.bookingsService.findById(updated!.id); } - async marketingApprove(bookingId: string, actorId: string): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - marketingApprovedById: actorId, - marketingApprovedAt: new Date(), - lockedAt: new Date(), - } as never); - - 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 { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PAID']); + assertBookingStatus(booking, ["PAID"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'IN_TRANSIT', + status: "IN_TRANSIT", } as never); return this.bookingsService.findById(updated!.id); } async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['IN_TRANSIT']); + assertBookingStatus(booking, ["IN_TRANSIT"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'COMPLETED', + status: "COMPLETED", endDate: new Date(), } as never); return this.bookingsService.findById(updated!.id); @@ -462,22 +452,23 @@ export class BookingTransitionService { async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'CHANGES_REQUESTED', - 'PENDING_APPROVAL', - 'CONTRACT_READY', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "PENDING_APPROVAL", + "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CANCELLED', + status: "CANCELLED", } as never); return this.bookingsService.findById(updated!.id); } @@ -490,20 +481,20 @@ export class BookingTransitionService { async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'PENDING_CONSOLIDATION', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_CONSOLIDATION", ]); await this.bookingsRepository.createReviewNote( bookingId, - reason?.trim() || 'Customer rejected the price estimate.', - 'REJECTION', + reason?.trim() || "Customer rejected the price estimate.", + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -524,10 +515,10 @@ export class BookingTransitionService { fileKey: string; label: string; required: boolean; - uploadedBy: 'customer' | 'gl'; + uploadedBy: "customer" | "gl"; settingCode: string; file: { id: string; name: string; url: string } | null; - reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null; note: string | null; }>; allApproved: boolean; @@ -547,20 +538,21 @@ export class BookingTransitionService { const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const files = await this.filesService.findByResource(bookingId, "bookings"); const fileByCode = new Map(files.map((f) => [f.code, f])); - const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviews = + await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< - ReturnType - >['documents'] = []; + ReturnType + >["documents"] = []; const pushSetting = async ( code: string | null, - uploadedBy: 'customer' | 'gl', + uploadedBy: "customer" | "gl", ) => { if (!code) return; let setting; @@ -578,28 +570,26 @@ export class BookingTransitionService { required: field.isRequired, uploadedBy, settingCode: code, - file: file - ? { id: file.id, name: file.name, url: file.url } - : null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; - await pushSetting(inputCode, 'customer'); - await pushSetting(outputCode, 'gl'); + await pushSetting(inputCode, "customer"); + await pushSetting(outputCode, "gl"); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { - if (!f.code?.startsWith('custom_')) continue; + if (!f.code?.startsWith("custom_")) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, - uploadedBy: 'customer', - settingCode: 'custom', + uploadedBy: "customer", + settingCode: "custom", file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, @@ -633,13 +623,15 @@ export class BookingTransitionService { } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; - const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + const reviews = await this.bookingsRepository.findDocumentReviews( + booking.id, + ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && - r.status === 'APPROVED', + r.status === "APPROVED", ), ); } @@ -654,33 +646,38 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + ]); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { - throw new BadRequestException('This booking has no document-clearance step'); + throw new BadRequestException( + "This booking has no document-clearance step", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. - if (booking.status === 'AWAITING_DOCUMENTS') { + if (booking.status === "AWAITING_DOCUMENTS") { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. - const settingCode = file.fieldname.startsWith('custom_') - ? 'custom' + const settingCode = file.fieldname.startsWith("custom_") + ? "custom" : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, @@ -691,7 +688,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(bookingId, { - status: 'DOCUMENTS_UNDER_REVIEW', + status: "DOCUMENTS_UNDER_REVIEW", } as never); if (this.isPhasedGeneralCustoms(booking)) { @@ -728,7 +725,10 @@ export class BookingTransitionService { const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; - const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const existing = await this.filesService.findByResource( + bookingId, + "bookings", + ); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), @@ -736,7 +736,7 @@ export class BookingTransitionService { const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { - const labels = missing.map((f) => f.fileLabel).join(', '); + const labels = missing.map((f) => f.fileLabel).join(", "); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); @@ -747,22 +747,27 @@ export class BookingTransitionService { async reviewDocument( bookingId: string, fileKey: string, - status: 'APPROVED' | 'QUERIED', + status: "APPROVED" | "QUERIED", staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { inputCode, outputCode } = clearanceCodesForBooking(booking); - const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const existing = + await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? - (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + (fileKey.startsWith("custom_") + ? "custom" + : (inputCode ?? outputCode ?? "custom")); - if (status === 'QUERIED' && !note?.trim()) { - throw new BadRequestException('A note is required when querying a document'); + if (status === "QUERIED" && !note?.trim()) { + throw new BadRequestException( + "A note is required when querying a document", + ); } if ( status === 'QUERIED' && @@ -782,11 +787,11 @@ export class BookingTransitionService { staffId, note, ); - if (status === 'QUERIED') { + if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", staffId, ); if (this.isPhasedGeneralCustoms(booking)) { @@ -821,18 +826,20 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { - throw new BadRequestException('This booking has no customs output documents'); + throw new BadRequestException( + "This booking has no customs output documents", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); @@ -856,14 +863,18 @@ export class BookingTransitionService { const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( - 'All required documents must be approved before clearance can be finalized', + "All required documents must be approved before clearance can be finalized", ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { - const setting = await this.fileUploadSettingsService.getByCode(outputCode); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const setting = + await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource( + bookingId, + "bookings", + ); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), @@ -872,13 +883,13 @@ export class BookingTransitionService { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) - .join(', ')}`, + .join(", ")}`, ); } } await this.bookingsRepository.update(bookingId, { - status: 'CLEARANCE_READY', + status: "CLEARANCE_READY", } as never); return this.bookingsService.findById(bookingId); } @@ -897,11 +908,14 @@ export class BookingTransitionService { scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + assertBookingStatus(booking, [ + "CLEARANCE_READY", + "OPERATION_CHANGES_REQUESTED", + ]); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { - throw new BadRequestException('A valid schedule date is required'); + throw new BadRequestException("A valid schedule date is required"); } // The binding shipment day must have at least one OPEN departure on the @@ -914,12 +928,12 @@ export class BookingTransitionService { ); if (!hasDeparture) { throw new BadRequestException( - 'No departures available on the selected day for this route', + "No departures available on the selected day for this route", ); } await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUEST_PENDING', + status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); @@ -935,27 +949,27 @@ export class BookingTransitionService { */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES', + decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]); - if (decision === 'REQUEST_CHANGES') { + if (decision === "REQUEST_CHANGES") { if (!options.note?.trim()) { throw new BadRequestException( - 'A note is required when requesting changes', + "A note is required when requesting changes", ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', + status: "OPERATION_CHANGES_REQUESTED", } as never); return this.bookingsService.findById(bookingId); } @@ -977,9 +991,17 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, + ); + await this.invoiceService.updateStatus( + invoice.id, + Freight.InvoiceStatus.Pending, + ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { - status: 'ROAD_DISPATCH_PENDING', + status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -987,7 +1009,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', + status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -1002,21 +1024,23 @@ export class BookingTransitionService { return this.bookingsService.findById(booking.id); } - async enrichBookingResponse(booking: Booking): Promise { + async enrichBookingResponse(booking: Booking): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + } + > { const note = await this.bookingsRepository.findLatestReviewNote( booking.id, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", ); const summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); const nextPending = - booking.status === 'PENDING_APPROVAL' || - booking.status === 'APPROVED_PENDING_SIGNATURE' + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); @@ -1027,4 +1051,4 @@ export class BookingTransitionService { nextStep, }; } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 01452d036..8c693af17 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -28,8 +28,8 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from '@nestjs/swagger'; -import type { Response } from 'express'; +} from "@nestjs/swagger"; +import type { Response } from "express"; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; @@ -58,18 +58,21 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from './dto/request-changes.dto'; -import { ContractViewDto } from './dto/contract-view.dto'; -import { SignContractDto } from './dto/sign-contract.dto'; -import { UpdateBookingDto } from './dto/update-booking.dto'; +} from "./dto/request-changes.dto"; +import { ContractViewDto } from "./dto/contract-view.dto"; +import { SignContractDto } from "./dto/sign-contract.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; import { type AuthUserPayload, resolveAuthUserId, -} from '../../common/resolve-auth-user-id'; -import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +} from "../../common/resolve-auth-user-id"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; -@ApiTags('bookings') -@Controller('bookings') +@ApiTags("bookings") +@Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( @@ -83,8 +86,8 @@ export class BookingsController { @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @@ -94,15 +97,24 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - const result = await this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create( + dto, + files ?? [], + user?.id, + ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.bookings.staffAccept, + ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); - const submitted = await this.bookingsService.findById(result.booking.id); + const submitted = await this.bookingsService.findById( + result.booking.id, + ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. @@ -112,16 +124,16 @@ export class BookingsController { return result; } - @Patch(':id') + @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Update booking', - description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + summary: "Update booking", + description: "Allowed when status is DRAFT or CHANGES_REQUESTED.", }) @ApiBody({ type: UpdateBookingDto }) update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { @@ -129,7 +141,7 @@ export class BookingsController { } @Get() - @ApiOperation({ summary: 'List freight bookings (paginated)' }) + @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, @@ -146,7 +158,7 @@ export class BookingsController { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; - if (!userId) throw new UnauthorizedException('Authentication required'); + if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). @@ -172,27 +184,29 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } - @Get('by-company/:companyId/customer-view') - @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + @Get("by-company/:companyId/customer-view") + @ApiOperation({ + summary: "List bookings for a company (customer-view shape, backoffice)", + }) findByCompanyCustomerView( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } - @Get('list-summary') - @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @Get("list-summary") + @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } - @Get('my') + @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: - 'Bookings owned by the authenticated user\'s company that are payable ' + - '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + "Bookings owned by the authenticated user's company that are payable " + + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @@ -201,32 +215,32 @@ export class BookingsController { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } - @Get('queues/:queue') + @Get("queues/:queue") @ApiOperation({ - summary: 'List bookings for a dashboard queue', - description: 'Queues: intake, approval, signatures, marketing, finance', + summary: "List bookings for a dashboard queue", + description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( - @Param('queue') queue: string, + @Param("queue") queue: string, @Query() filter: FilterBookingDto, - @Query('excludeBulk') excludeBulk?: string, + @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { - excludeBulk: excludeBulk === 'true', + excludeBulk: excludeBulk === "true", }); } - @Get('reference-data') - @ApiOperation({ summary: 'Booking form catalog' }) + @Get("reference-data") + @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - @Get('by-reference/:reference') - @ApiOperation({ summary: 'Get booking by reference' }) + @Get("by-reference/:reference") + @ApiOperation({ summary: "Get booking by reference" }) async findByReference( - @Param('reference') reference: string, + @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); @@ -240,10 +254,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id') - @ApiOperation({ summary: 'Get booking by ID' }) + @Get(":id") + @ApiOperation({ summary: "Get booking by ID" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -261,15 +275,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/tracking') + @Get(":id/tracking") @ApiOperation({ - summary: 'Shipment tracking timeline for a booking', + summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + - 'tracking events. Scoped to the customer\'s own company.', + "tracking events. Scoped to the customer's own company.", }) async findTracking( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -283,66 +297,66 @@ export class BookingsController { return this.bookingsService.getBookingTracking(id); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete DRAFT booking" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - @Post(':id/documents') + @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/generate-price') + @Post(":id/generate-price") @ApiOperation({ - summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: - 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) - generatePrice(@Param('id', ParseUUIDPipe) id: string) { + generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } - @Post(':id/submit') + @Post(":id/submit") @ApiOperation({ - summary: 'Customer submit booking', + summary: "Customer submit booking", description: - 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - submit(@Param('id', ParseUUIDPipe) id: string) { + submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } - @Post(':id/confirm-submit') + @Post(":id/confirm-submit") @ApiOperation({ - summary: 'Confirm submit after price change', + summary: "Confirm submit after price change", description: - 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } - @Post(':id/reject') + @Post(":id/reject") @ApiOperation({ - summary: 'Customer reject price estimate', + summary: "Customer reject price estimate", description: - 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', + "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); @@ -367,20 +381,21 @@ export class BookingsController { @Get(':id/clearance') @ApiOperation({ - summary: 'Document-clearance grid (required docs + upload + GL review status)', + summary: + "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param('id', ParseUUIDPipe) id: string) { + getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } - @Post(':id/clearance/documents') + @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Customer uploads clearance documents (fieldname = document key)', + summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( @@ -390,14 +405,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/proceed') + @Post(":id/clearance/proceed") @ApiOperation({ summary: - 'Customer requests operation with a schedule day ' + - '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', + "Customer requests operation with a schedule day " + + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", }) async proceedToOperation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( @@ -407,15 +422,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/review') + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Operations reviews an operation request: ACCEPT (→ batch pool), ' + - 'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', + "Operations reviews an operation request: ACCEPT (→ batch pool), " + + "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)", }) async reviewOperationRequest( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { @@ -428,11 +443,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/review') + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) - @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) + @ApiOperation({ + summary: "GL reviews a clearance document (Approve | Query)", + }) async reviewClearanceDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { @@ -446,13 +463,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/output-documents') + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( @@ -462,12 +479,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize') + @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ - summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', + summary: + "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) - async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } @@ -628,9 +646,9 @@ export class BookingsController { @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) - @ApiOperation({ summary: 'Staff return booking for customer updates' }) + @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { @@ -642,14 +660,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/accept') + @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: - 'Staff accept intake → set contract validity window + start approval chain', + "Staff accept intake → set contract validity window + start approval chain", }) async acceptIntake( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { @@ -661,11 +679,11 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/reject') + @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) - @ApiOperation({ summary: 'Staff final reject' }) + @ApiOperation({ summary: "Staff final reject" }) async staffReject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { @@ -677,11 +695,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/government-expedite') + @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + @ApiOperation({ + summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", + }) async governmentExpedite( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( @@ -691,16 +711,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/approve') + @Post(":id/approval-steps/:stepId/approve") @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveCeo, ]) - @ApiOperation({ summary: 'Approve one approval step in sequence' }) + @ApiOperation({ summary: "Approve one approval step in sequence" }) async approveStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { @@ -714,12 +734,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/reject') + @Post(":id/approval-steps/:stepId/reject") @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: 'Reject at approval step' }) + @ApiOperation({ summary: "Reject at approval step" }) async rejectStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { @@ -732,53 +752,53 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/contract/generate') + @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) - @ApiOperation({ summary: 'Generate contract PDF from template' }) - async generateContract(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Generate contract PDF from template" }) + async generateContract(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/view') + @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) - @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } - @Get(':id/contract/document') - @ApiOperation({ summary: 'Download contract PDF' }) + @Get(":id/contract/document") + @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); - res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader("Content-Type", record.mimeType ?? "application/pdf"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file (alias)' }) + @Get(":id/contract") + @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } - @Post(':id/contract/sign') - @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + @Post(":id/contract/sign") + @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { @@ -790,28 +810,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/signatures') - @ApiOperation({ summary: 'List contract signatures' }) - getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/contract/signatures") + @ApiOperation({ summary: "List contract signatures" }) + getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } - @Get(':id/summary') - @ApiOperation({ summary: 'Contract summary string for dashboard' }) - getSummary(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/summary") + @ApiOperation({ summary: "Contract summary string for dashboard" }) + getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } - @Post(':id/customer/sign') + @Post(":id/customer/sign") @ApiOperation({ - summary: 'Customer digital signature (deprecated — use POST contract/sign)', + summary: "Customer digital signature (deprecated — use POST contract/sign)", }) async customerSign( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { - const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, @@ -819,20 +839,21 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/marketing/approve') + @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ - summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + summary: + "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, - role: 'STAFF', + role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), @@ -841,48 +862,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/start-transit') + @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark in transit' }) - async startTransit(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark in transit" }) + async startTransit(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/complete') + @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark completed' }) - async complete(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark completed" }) + async complete(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/cancel') + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ summary: "Cancel booking" }) async cancel( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/consolidation') - @ApiOperation({ summary: 'Request freight consolidation' }) - requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Post(":id/consolidation") + @ApiOperation({ summary: "Request freight consolidation" }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - @Delete(':id/consolidation') - @ApiOperation({ summary: 'Remove consolidation pairing' }) - removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Delete(":id/consolidation") + @ApiOperation({ summary: "Remove consolidation pairing" }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - @Get(':id/consolidation') - @ApiOperation({ summary: 'Get consolidation details' }) - getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/consolidation") + @ApiOperation({ summary: "Get consolidation details" }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } } 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 dcdd1cea2..2cc23b3fa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,7 +1,7 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -14,13 +14,13 @@ 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 { BookingPaymentController } from './booking-payment.controller'; +// import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; -import { PayController } from './pay.controller'; +// import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; @@ -39,6 +39,8 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsModule } from '../contracts/contracts.module'; +import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; + @Module({ imports: [ @@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), BillingModule, forwardRef(() => FirstMileModule), @@ -67,10 +70,10 @@ import { ContractsModule } from '../contracts/contracts.module'; ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, + config.get("app.cbeExchange") ?? {}, }), ], - controllers: [BookingsController, PayController, BookingPaymentController], + controllers: [BookingsController], providers: [ BookingsService, BookingsRepository, @@ -80,13 +83,17 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingTransitionService, BookingContractService, BookingInvoiceService, - BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], + exports: [ + BookingsService, + BookingsRepository, + BookingPricingService, + BookingInvoiceService, + ], }) -export class BookingsModule {} +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 9c0b4931f..2d3901414 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -43,6 +43,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -1380,4 +1381,35 @@ export class BookingsService { createdAt: b.createdAt, })); } + + async allocateContainers( + bookingId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const booking = await this.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + }); + await manager.insert(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..8b9b7da39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class ContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateContainersDto { + allocations!: ContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts new file mode 100644 index 000000000..8cb186e09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'booking_container_allocations' }) +@Index(['bookingId']) +@Index(['vehicleId']) +export class BookingContainerAllocation extends BaseEntity { + @ManyToOne(() => Booking, (b) => b.containerAllocations) + @JoinColumn({ name: 'booking_id' }) + booking!: Booking; + + @Column('uuid', { name: 'booking_id' }) + bookingId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string; + + @Column('text') + containerType!: string; // CONTAINER, BULK_DRY, etc + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 60e3314bf..3ae0fb64f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity'; import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; +import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; @@ -476,6 +477,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; + @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) + containerAllocations?: BookingContainerAllocation[]; + @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts deleted file mode 100644 index 25f1927d3..000000000 --- a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { BookingPaymentService } from './booking-payment.service'; -// import { BookingTransitionService } from './booking-transition.service'; -// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -// import { Booking } from './entities/booking.entity'; -// import { BookingNextStep } from './booking-next-step.util'; - -@ApiTags('payments') -@ApiBearerAuth() -@Controller('bookings') -export class PayController { - constructor( - private readonly paymentService: BookingPaymentService, - // private readonly transitionService: BookingTransitionService, - ) { } - - @Post(':id/payment/pay') - @ApiOperation({ summary: 'Complete in-app payment (mock)' }) - @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) - async pay(@Param('id', ParseUUIDPipe) id: string) { - return await this.paymentService.pay(id); - // const abstract = await this.transitionService.enrichBookingResponse(booking); - // return { ...abstract, paymentReceipt: receipt }; - } -} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..b750f1147 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class FirstMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateFirstMileContainersDto { + allocations!: FirstMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index e2535083e..45e9f5b1a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity'; @@ -58,4 +58,9 @@ export class CreateFirstMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts new file mode 100644 index 000000000..b0fa54c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-container-allocation.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { FirstMile } from './first-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'first_mile_container_allocations', schema: 'freight' }) +@Index(['firstMileId']) +@Index(['vehicleId']) +export class FirstMileContainerAllocation extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, { + nullable: false, + eager: false, + }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column({ name: 'container_type', type: 'text' }) + containerType!: string; + + @Column({ name: 'quantity', type: 'int', default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index e810d23cc..27dcfef87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -34,6 +35,10 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; @@ -49,4 +54,11 @@ export class FirstMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany( + () => FirstMileContainerAllocation, + (containerAllocation) => containerAllocation.firstMile, + { eager: false }, + ) + containerAllocations!: FirstMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts new file mode 100644 index 000000000..22520aac1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FirstMileRepository } from './first-mile.repository'; +import { FirstMile } from './entities/first-mile.entity'; + +/** + * Owns the first-mile ⇄ invoice mapping — the one place that knows how a + * first-mile record turns into invoices, which type to use, and how it + * advances when paid. First-mile records are billable entities, so they + * generate their own invoices directly via {@link BillingService}. + */ +@Injectable() +export class FirstMileInvoiceService { + private readonly logger = new Logger(FirstMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly firstMileRepo: FirstMileRepository, + ) {} + + /** + * Ensure the first-mile record has its invoice, generating one from the + * remaining payment if absent. Called when a first-mile record reaches a + * billable state. Idempotent — returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill. + */ + async ensureInvoiceFor(record: FirstMile): Promise { + const existing = await this.billing.findPayable( + 'first_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + if (!record.bookingId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no booking to reference.`, + ); + return null; + } + + // Fetch the booking to get the companyId and companyProfileId + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } })); + if (!fm) return null; + if (!fm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + const totalAmount = record.remainingPayment || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + + return this.billing.generateInvoice({ + source: 'first_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: fm.booking!.companyId, + companyProfileId: fm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'First-mile delivery', + quantity: 1, + unitRate: totalAmount, + amount: totalAmount, + }, + ], + totalAmount, + }); + } + + /** + * React to a first-mile invoice being paid — the settlement branch point. + * Mark the first-mile record as having completed post-payment processing. + */ + @OnEvent('first_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.firstMileRepo.findById(payload.sourceId); + if (!record) { + this.logger.warn( + `Cannot mark unknown first-mile record ${payload.sourceId} as paid.`, + ); + return; + } + + this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 78c3d43ff..680bb5d09 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -17,15 +17,25 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { BookingsService } from '../bookings/bookings.service'; +import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @Controller('first-mile') @TrainSchedulingView() export class FirstMileController { - constructor(private readonly firstMileService: FirstMileService) {} + constructor( + private readonly firstMileService: FirstMileService, + private readonly firstMileInvoiceService: FirstMileInvoiceService, + private readonly billingService: BillingService, + private readonly bookingsService: BookingsService + ) { } @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -72,8 +82,40 @@ export class FirstMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - return this.firstMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { + const record = await this.firstMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + const booking = await this.bookingsService.findById(record.bookingId); + if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.FirstMile, + sourceId: record.id, + type: "FIRST_MILE", + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: "ETB", + + lines: [ + { + chargeType: "FIRST_MILE", + description: "First Mile Transportation Service", + quantity: 1, + unitRate: record.remainingPayment, + amount: record.remainingPayment, + currency: "ETB", + }, + ], + + subtotalAmount: record.remainingPayment, + taxAmount: 0, // Replace if VAT/tax applies + totalAmount: record.remainingPayment, + + dueInDays: 7, + status: Freight.InvoiceStatus.Pending, + }); + await this.firstMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') @@ -83,4 +125,14 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } + + @Post(':firstMileId/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) + allocateContainers( + @Param('firstMileId', ParseUUIDPipe) firstMileId: string, + @Body() dto: AllocateFirstMileContainersDto, + ) { + return this.firstMileService.allocateContainers(firstMileId, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index bf6815af7..a69c920f1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,25 +1,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileController } from './first-mile.controller'; +import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [FirstMileController], - providers: [FirstMileRepository, FirstMileService], - exports: [FirstMileRepository, FirstMileService], + providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], + exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService], }) export class FirstMileModule {} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 45c2658db..ae0ada831 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,7 +10,10 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; +import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InvoiceEventPayload } from '../billing/billing.service'; type FirstMileListFilter = { status?: FirstMileStatus; @@ -32,6 +37,7 @@ export class FirstMileService { private readonly logger = new Logger(FirstMileService.name); constructor( + @InjectDataSource() private readonly dataSource: DataSource, private readonly firstMileRepository: FirstMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, @@ -142,6 +148,18 @@ export class FirstMileService { }; } + @OnEvent("firstmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { @@ -171,6 +189,7 @@ export class FirstMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } @@ -203,6 +222,7 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -211,7 +231,8 @@ export class FirstMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); @@ -273,4 +294,35 @@ export class FirstMileService { await this.findById(id); await this.firstMileRepository.softDelete(id); } + + async allocateContainers( + firstMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const firstMile = await this.findById(firstMileId); + if (!firstMile) { + throw new NotFoundException(`First-mile record ${firstMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + }); + await manager.insert(FirstMileContainerAllocation, { + firstMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts new file mode 100644 index 000000000..254af7104 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator'; +import { PaymentMethod } from '../entities/fuel-purchase.entity'; + +export class CreateFuelPurchaseDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + purchaseDate!: string; + + @IsNumber() + liters!: number; + + @IsNumber() + costPerLiter!: number; + + @IsOptional() + @IsString() + fuelStation?: string; + + @IsEnum(PaymentMethod) + @IsOptional() + paymentMethod?: PaymentMethod; + + @IsOptional() + @IsNumber() + odometerReading?: number; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsString() + receiptNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts new file mode 100644 index 000000000..aabafd17c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'fuel_consumption', schema: 'freight' }) +@Index(['vehicleId', 'month']) +export class FuelConsumption extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'month', type: 'date' }) + month!: Date; + + @Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 }) + totalLiters!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 }) + totalDistanceKm: number = 0; + + @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) + fuelEfficiencyKmPerL?: number; + + @Column({ name: 'number_of_purchases', type: 'integer', default: 0 }) + numberOfPurchases!: number; + + @Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true }) + averageCostPerLiter?: number; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts new file mode 100644 index 000000000..163618d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum PaymentMethod { + CASH = 'CASH', + CARD = 'CARD', + FUEL_CARD = 'FUEL_CARD', + TRANSFER = 'TRANSFER', + CHEQUE = 'CHEQUE', +} + +@Entity({ name: 'fuel_purchases', schema: 'freight' }) +export class FuelPurchase extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'purchase_date', type: 'timestamptz' }) + purchaseDate!: Date; + + @Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 }) + liters!: number; + + @Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 }) + costPerLiter!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'fuel_station', nullable: true }) + fuelStation?: string; + + @Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH }) + paymentMethod!: PaymentMethod; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @Column({ name: 'receipt_number', nullable: true }) + receiptNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts new file mode 100644 index 000000000..62207bfa1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { FuelService } from './fuel.service'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@ApiTags('Fuel Management') +@Controller('fuel') +export class FuelController { + constructor(private readonly fuelService: FuelService) {} + + @Post('purchases') + @ApiOperation({ summary: 'Record fuel purchase' }) + async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) { + return this.fuelService.recordFuelPurchase(dto); + } + + @Get('purchases') + @ApiOperation({ summary: 'Get all fuel purchases' }) + async getAllFuelPurchases() { + return this.fuelService.getAllFuelPurchases(); + } + + @Get('purchases/:vehicleId') + @ApiOperation({ summary: 'Get fuel purchases for vehicle' }) + async getFuelPurchases( + @Param('vehicleId') vehicleId: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.fuelService.getFuelPurchases( + vehicleId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get('consumption/:vehicleId/:month') + @ApiOperation({ summary: 'Get monthly fuel consumption' }) + async getMonthlyConsumption( + @Param('vehicleId') vehicleId: string, + @Param('month') month: string, + ) { + return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month)); + } + + @Get('stats') + @ApiOperation({ summary: 'Get fleet-wide fuel statistics' }) + async getFleetFuelStats(@Query('months') months: number = 12) { + return this.fuelService.getFleetFuelStats(months); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get fuel statistics for vehicle' }) + async getVehicleFuelStats( + @Param('vehicleId') vehicleId: string, + @Query('months') months: number = 12, + ) { + return this.fuelService.getVehicleFuelStats(vehicleId, months); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.module.ts b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts new file mode 100644 index 000000000..258350f1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FuelController } from './fuel.controller'; +import { FuelService } from './fuel.service'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])], + controllers: [FuelController], + providers: [FuelService, FuelRepository], + exports: [FuelService], +}) +export class FuelModule {} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts new file mode 100644 index 000000000..d062c38e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.purchaseRepository.find({ + where: { + vehicleId, + purchaseDate: Between(startDate, endDate), + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + let consumption = await this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = this.consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + return this.purchaseRepository.find({ + where: { vehicleId }, + order: { purchaseDate: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts new file mode 100644 index 000000000..54c157c2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@Injectable() +export class FuelService { + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.purchaseRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.purchaseRepository.save(purchase); + + // Update monthly consumption + await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); + + return saved; + } + + async getAllFuelPurchases(): Promise { + return this.purchaseRepository + .createQueryBuilder('purchase') + .leftJoinAndSelect('purchase.vehicle', 'vehicle') + .orderBy('purchase.purchaseDate', 'DESC') + .getMany(); + } + + async getFuelPurchases( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.fuelRepository.getMonthlyConsumption(vehicleId, month); + } + + async getFleetFuelStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.purchaseRepository + .createQueryBuilder('purchase') + .where('purchase.purchaseDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + averageEfficiency: 0, // Placeholder - would need distance data + dateRange: { startDate, endDate }, + }; + } + + async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + vehicleId, + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + dateRange: { startDate, endDate }, + }; + } + + private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); + + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const numberOfPurchases = purchases.length; + const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; + + await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, { + totalLiters, + totalCost, + numberOfPurchases, + averageCostPerLiter, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..de86ac883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class LastMileContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateLastMileContainersDto { + allocations!: LastMileContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index 4f6f5fc8f..08de632f1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity'; @@ -58,4 +58,9 @@ export class CreateLastMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts new file mode 100644 index 000000000..8a61c73bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { LastMile } from './last-mile.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'last_mile_container_allocations' }) +@Index(['lastMileId']) +@Index(['vehicleId']) +export class LastMileContainerAllocation extends BaseEntity { + @ManyToOne(() => LastMile, (lm) => lm.containerAllocations) + @JoinColumn({ name: 'last_mile_id' }) + lastMile!: LastMile; + + @Column('uuid', { name: 'last_mile_id' }) + lastMileId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string | null; + + @Column('text') + containerType!: string; + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index ad4b789f4..85d01b7f0 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -34,6 +35,10 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; @@ -49,4 +54,7 @@ export class LastMile extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: true, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle | null; + + @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) + containerAllocations?: LastMileContainerAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts new file mode 100644 index 000000000..c304a89e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, +} from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { LastMileRepository } from './last-mile.repository'; +import { LastMile } from './entities/last-mile.entity'; + +/** + * Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile + * record turns into invoices, which type to use, and how it advances when paid. + * Last-mile records are billable business entities for delivery fees, so they + * generate their own invoices directly via {@link BillingService}. All last-mile-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class LastMileInvoiceService { + private readonly logger = new Logger(LastMileInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly lastMileRepo: LastMileRepository, + ) {} + + /** + * Ensure the last-mile record has its invoice, generating one from the + * remainingPayment if absent. Called when a last-mile record reaches a + * billable state. Idempotent — returns the existing open invoice instead + * of a duplicate. Returns `null` (and logs) when the record is not billable: + * no company to bill (invoices FK requires a companyId). + */ + async ensureInvoiceFor(record: LastMile): Promise { + // Check if invoice already exists + const existing = await this.billing.findPayable( + 'last_mile' as Freight.InvoiceSource, + record.id, + 'DELIVERY_FEE', + ); + if (existing) return existing; + + // Can't bill without company + const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } })); + if (!lm) return null; + if (!lm.booking?.companyId) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no company to bill.`, + ); + return null; + } + + // Generate invoice with remainingPayment as totalAmount + const input: GenerateInvoiceInput = { + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: 'ETB', + lines: [ + { + chargeType: 'DELIVERY', + description: 'Last-mile delivery', + quantity: 1, + unitRate: record.remainingPayment || 0, + amount: record.remainingPayment || 0, + }, + ], + totalAmount: record.remainingPayment || 0, + }; + + return this.billing.generateInvoice(input); + } + + /** + * React to a last-mile invoice being paid — the settlement branch point. + * Advances the last-mile record to mark post-payment as completed. + */ + @OnEvent('last_mile.invoice.paid') + async onPaid(payload: InvoiceEventPayload): Promise { + if (payload.type === 'DELIVERY_FEE') { + const record = await this.lastMileRepo.findById(payload.sourceId); + if (record) { + this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`); + } else { + this.logger.warn( + `Cannot mark last-mile record ${payload.sourceId} as paid: not found.`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e8abf52c6..88a96e6c2 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -17,15 +17,25 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; +import { Freight } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { BookingsService } from '../bookings/bookings.service'; @ApiTags('last-mile') @ApiBearerAuth() @Controller('last-mile') @TrainSchedulingView() export class LastMileController { - constructor(private readonly lastMileService: LastMileService) {} + constructor( + private readonly lastMileService: LastMileService, + private readonly lastMileInvoiceService: LastMileInvoiceService, + private readonly billingService: BillingService, + private readonly bookingsService: BookingsService + ) {} @Get() @ApiOperation({ summary: 'List last-mile legs' }) @@ -72,8 +82,40 @@ export class LastMileController { @Patch(':id') @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - return this.lastMileService.update(id, dto); + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { + const record = await this.lastMileService.update(id, dto); + // Auto-generate invoice if distance or payment was updated + const booking = await this.bookingsService.findById(record.bookingId); + if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.LastMile, + sourceId: record.id, + type: "LAST_MILE", + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: "ETB", + + lines: [ + { + chargeType: "LAST_MILE", + description: "Last Mile Transportation Service", + quantity: 1, + unitRate: record.remainingPayment, + amount: record.remainingPayment, + currency: "ETB", + }, + ], + + subtotalAmount: record.remainingPayment, + taxAmount: 0, // Replace if VAT/tax applies + totalAmount: record.remainingPayment, + + dueInDays: 7, + status: Freight.InvoiceStatus.Pending, + }); + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } + return record; } @Delete(':id') @@ -83,4 +125,14 @@ export class LastMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.lastMileService.remove(id); } + + @Post(':id/allocate-containers') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AllocateLastMileContainersDto, + ) { + return this.lastMileService.allocateContainers(id, dto.allocations); + } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e4b99a18c..32b688069 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,25 +1,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileController } from './last-mile.controller'; +import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + BillingModule, forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, ], controllers: [LastMileController], - providers: [LastMileRepository, LastMileService], - exports: [LastMileRepository, LastMileService], + providers: [LastMileRepository, LastMileService, LastMileInvoiceService], + exports: [LastMileRepository, LastMileService, LastMileInvoiceService], }) export class LastMileModule {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 77a8a2fea..732b1a618 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -8,7 +8,10 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; +import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; +import { InvoiceEventPayload } from '../billing/billing.service'; +import { OnEvent } from '@nestjs/event-emitter'; type LastMileListFilter = { status?: LastMileStatus; @@ -37,6 +40,7 @@ export class LastMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly dataSource: DataSource, ) {} async acceptBooking(bookingReference: string): Promise { @@ -135,12 +139,26 @@ export class LastMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } + @OnEvent("lastmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -149,7 +167,8 @@ export class LastMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); @@ -206,4 +225,35 @@ export class LastMileService { await this.findById(id); await this.lastMileRepository.softDelete(id); } + + async allocateContainers( + lastMileId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const lastMile = await this.findById(lastMileId); + if (!lastMile) { + throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + } + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + }); + await manager.insert(LastMileContainerAllocation, { + lastMileId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts new file mode 100644 index 000000000..d3e70acff --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -0,0 +1,87 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity'; + +export class CreateMaintenanceScheduleDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + @IsString() + description!: string; + + @IsDateString() + scheduledDate!: string; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + nextDueKm?: number; + + @IsOptional() + @IsDateString() + nextDueDate?: string; +} + +export class CreateMaintenanceCostDto { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsUUID() + maintenanceScheduleId?: string; + + @IsDateString() + incurredDate!: string; + + @IsNumber() + costAmount!: number; + + @IsString() + costType!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + invoiceNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateMaintenanceScheduleDto { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; + + @IsOptional() + @IsNumber() + actualCost?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts new file mode 100644 index 000000000..5afbaa80f --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceSchedule } from './maintenance-schedule.entity'; + +@Entity({ name: 'maintenance_costs', schema: 'freight' }) +@Index(['vehicleId', 'incurredDate']) +export class MaintenanceCost extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true }) + maintenanceScheduleId?: string; + + @ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'maintenance_schedule_id' }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ name: 'incurred_date', type: 'timestamptz' }) + incurredDate!: Date; + + @Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 }) + costAmount!: number; + + @Column({ name: 'cost_type' }) + costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER' + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'invoice_number', nullable: true }) + invoiceNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts new file mode 100644 index 000000000..a4d4d60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + INSPECTION = 'INSPECTION', + REPAIR = 'REPAIR', +} + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + OVERDUE = 'OVERDUE', +} + +@Entity({ name: 'maintenance_schedules', schema: 'freight' }) +@Index(['vehicleId', 'scheduledDate']) +export class MaintenanceSchedule extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'completed_date', type: 'timestamptz', nullable: true }) + completedDate?: Date; + + @Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + estimatedCost?: number; + + @Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + actualCost?: number; + + @Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED }) + status!: MaintenanceStatus; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'next_due_km', type: 'numeric', nullable: true }) + nextDueKm?: number; + + @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) + nextDueDate?: Date; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..de5ff08f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@ApiTags('Maintenance Management') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Post('schedules') + @ApiOperation({ summary: 'Schedule maintenance' }) + async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) { + return this.maintenanceService.scheduleMaintenanceAsync(dto); + } + + @Post('costs') + @ApiOperation({ summary: 'Record maintenance cost' }) + async recordCost(@Body() dto: CreateMaintenanceCostDto) { + return this.maintenanceService.recordMaintenanceCost(dto); + } + + @Patch('schedules/:id') + @ApiOperation({ summary: 'Update maintenance schedule' }) + async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) { + return this.maintenanceService.updateMaintenanceSchedule(id, dto); + } + + @Get('upcoming/:vehicleId') + @ApiOperation({ summary: 'Get upcoming maintenance' }) + async getUpcoming(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getUpcomingMaintenance(vehicleId); + } + + @Get('history/:vehicleId') + @ApiOperation({ summary: 'Get maintenance history' }) + async getHistory(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getMaintenanceHistory(vehicleId); + } + + @Get('stats') + @ApiOperation({ summary: 'Get fleet-wide maintenance statistics' }) + async getFleetStats() { + return this.maintenanceService.getFleetMaintenanceStats(); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get maintenance statistics' }) + async getStats(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts new file mode 100644 index 000000000..a0227a733 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])], + providers: [MaintenanceService, MaintenanceRepository], + controllers: [MaintenanceController], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 000000000..9e8cf972e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; + +@Injectable() +export class MaintenanceRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) { + super(scheduleRepository); + } + + async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + return this.scheduleRepository.find({ + where: { + vehicleId, + scheduledDate: Between(new Date(), futureDate), + status: MaintenanceStatus.SCHEDULED, + }, + order: { scheduledDate: 'ASC' }, + }); + } + + async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) { + return this.costRepository.find({ + where: { + vehicleId, + incurredDate: Between(startDate, endDate), + }, + order: { incurredDate: 'DESC' }, + }); + } + + async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) { + const result = await this.costRepository + .createQueryBuilder() + .select('SUM(cost_amount)', 'total') + .where('vehicle_id = :vehicleId', { vehicleId }) + .andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + return result?.total || 0; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts new file mode 100644 index 000000000..e804243a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@Injectable() +export class MaintenanceService { + constructor( + private readonly maintenanceRepository: MaintenanceRepository, + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) {} + + async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + scheduledDate: new Date(dto.scheduledDate), + nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, + }); + return this.scheduleRepository.save(schedule); + } + + async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { + const cost = this.costRepository.create({ + ...dto, + incurredDate: new Date(dto.incurredDate), + }); + return this.costRepository.save(cost); + } + + async updateMaintenanceSchedule( + id: string, + dto: UpdateMaintenanceScheduleDto, + ): Promise { + await this.scheduleRepository.update(id, { + ...dto, + completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, + }); + const updated = await this.scheduleRepository.findOneBy({ id }); + return updated!; + } + + async getUpcomingMaintenance(vehicleId: string) { + return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); + } + + async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + } + + async getFleetMaintenanceStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.costRepository + .createQueryBuilder('cost') + .where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + vehicleId, + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + private groupCostsByType(costs: MaintenanceCost[]) { + const grouped: Record = {}; + costs.forEach((c) => { + if (!grouped[c.costType]) grouped[c.costType] = 0; + grouped[c.costType] += Number(c.costAmount); + }); + return grouped; + } +} 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 b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -6,8 +6,6 @@ import { ParseUUIDPipe, Query, Res, - Body, - Post, } from "@nestjs/common"; import { ApiTags, @@ -18,9 +16,9 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { IntentStatusDto, RefundDto } from "./payments.dto"; +import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,13 +71,6 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } - @Post("refund") - @FreightAdmin() - @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) - refund(@Body() dto: RefundDto) { - return this.paymentService.refund(dto); - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) 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 f1eb3ad30..d92af7a3e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,13 +1,12 @@ import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,77 +15,70 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; -import { Invoice } from "../billing/entities/invoice.entity"; +import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { - ClientAction, - ProviderPaymentStatus, -} from "@edr/payment-providers"; -import { - Freight, - PaymentService as PaymentServiceEnum, - PaymentReferenceType, - PaymentIntentSnapshot, - ProviderMethod, + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, } from "@edr/types"; import { - InitiateResponseDto, - IntentStatusDto, - PaymentPlatformDto, - RefundDto, + InitiateResponseDto, + IntentStatusDto, + PaymentPlatformDto, } from "./payments.dto"; /** 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; + /** 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; + 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, - "processing": ProviderPaymentStatus.PROCESSING, - "success": ProviderPaymentStatus.SUCCEEDED, - "failed": ProviderPaymentStatus.FAILED, - "canceled": ProviderPaymentStatus.CANCELLED, - "refunded": ProviderPaymentStatus.CANCELLED, + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + processing: ProviderPaymentStatus.PROCESSING, + success: ProviderPaymentStatus.SUCCEEDED, + failed: ProviderPaymentStatus.FAILED, + canceled: ProviderPaymentStatus.CANCELLED, + 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", + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; /** @@ -98,449 +90,460 @@ const PROVIDER_TO_METHOD: Record = { */ @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); + private readonly logger = new Logger(PaymentService.name); - constructor( - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BillingService)) - private readonly billing: BillingService, - ) { } + constructor( + private readonly paymentRepo: PaymentRepository, + private readonly paymentClient: PaymentClientService, + @Inject(forwardRef(() => BillingService)) + private readonly billing: BillingService, + ) { } - async getAll(filters: { - search?: string; - status?: string; - method?: string; - page?: number; - pageSize?: number; - }) { - const { search, status, method, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { + const { search, status, method, page = 1, pageSize = 10 } = filters; + const skip = (page - 1) * pageSize; - const qb = this.paymentRepo.createQueryBuilder("payment"); + const qb = this.paymentRepo.createQueryBuilder("payment"); - if (search) { - qb.andWhere( - "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", - { search: `%${search}%` }, - ); - } - if (status) { - qb.andWhere("payment.status = :status", { status }); - } - if (method) { - qb.andWhere("payment.method = :method", { method }); - } + if (search) { + qb.andWhere( + "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", + { search: `%${search}%` }, + ); + } + if (status) { + qb.andWhere("payment.status = :status", { status }); + } + if (method) { + qb.andWhere("payment.method = :method", { method }); + } - const [items, total] = await qb - .orderBy("payment.createdAt", "DESC") - .skip(skip) - .take(pageSize) - .getManyAndCount(); + const [items, total] = await qb + .orderBy("payment.createdAt", "DESC") + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { + items: items.map((p) => ({ + id: p.id, + bookingId: p.refId, + amount: p.amount, + currency: p.currency, + method: p.method, + status: p.status, + merchantOrderId: p.merchantOrderId, + paidAt: p.paidAt, + createdAt: p.createdAt, + })), + total, + page, + pageSize, + }; + } + + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + + /** + * 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: 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 immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + + 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, + notify: false, + }); + } + + 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, + }; + } + + /** 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: input.referenceId, + }); + + const method: PaymentEntity["method"] = + PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); + + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; + const data = { + status, + method, + merchantOrderId: + snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", + transactionId: snapshot.providerTxnId ?? existing?.transactionId, + expiresAt: snapshot.expiresAt + ? new Date(snapshot.expiresAt) + : existing?.expiresAt, + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }; + + if (existing) { + await this.paymentRepo.update({ id: existing.id }, { + ...data, + clientAction, + } as any); + return { ...existing, ...data, clientAction } as PaymentEntity; + } + + return this.paymentRepo.create({ + 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); + } + + /** + * 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( + (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 reference ${referenceId}: ${message}; using local intent`, + ); + } + + if (!snapshot) { + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + if (!local) throw new NotFoundException("PaymentIntent not found"); + + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && + local.status !== "success"; + + 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: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + + /** + * 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 = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { + status: "success", + paidAt, + transactionId: opts.providerTxnId ?? intent.transactionId, + }, + ); + + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId, + paidAt, + ); + } + + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success" || intent.status === "canceled") return; + + await this.paymentRepo.update( + { 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 getActivePaymentByOrderIdAndMethod( + orderId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) + throw new BadRequestException( + "No successful payment found for this order", + ); + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); + + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + return template({ + vendorName: "Ethio Djibouti Railway Freight Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment.method, + subtotal: payment.amount.toString(), + total: payment.amount.toString(), + currency: payment.currency, + reason: payment.reason, + }); + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id }); + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + return { + intentId: intent.id, + status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } + + private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { + return { + ...this.formatIntentResponse(intent), + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failerCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; + } + + async handlePaymentEvent(event: { + eventType: string; + eventId: string; + referenceId: string; + intentId: string; + providerTxnId?: string; + paidAt?: string; + 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, + }); + if (!intent) { return { - items: items.map((p) => ({ - id: p.id, - bookingId: p.refId, - amount: p.amount, - currency: p.currency, - method: p.method, - status: p.status, - merchantOrderId: p.merchantOrderId, - paidAt: p.paidAt, - createdAt: p.createdAt, - })), - total, - page, - pageSize, + processed: false, + reason: `No local intent for reference ${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}`, + ); + + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. + return { processed: true, alreadyFinalized }; } - /** Aggregate counts across ALL payments for the dashboard summary cards. */ - async getSummary() { - const rows = await this.paymentRepo - .createQueryBuilder("payment") - .select("payment.status", "status") - .addSelect("COUNT(*)::int", "count") - .groupBy("payment.status") - .getRawMany<{ status: string; count: number }>(); - - const byStatus: Record = {}; - let total = 0; - for (const row of rows) { - byStatus[row.status] = row.count; - total += row.count; - } - - const paidAgg = await this.paymentRepo - .createQueryBuilder("payment") - .select("COALESCE(SUM(payment.amount), 0)", "sum") - .where("payment.status = :status", { status: "success" }) - .getRawOne<{ sum: string }>(); - + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - total, - success: byStatus["success"] ?? 0, - processing: - (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), - failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), - refunded: byStatus["refunded"] ?? 0, - paidAmount: Number(paidAgg?.sum ?? 0), + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; } - /** - * 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: 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", - }); + return { + processed: false, + reason: `Unknown event type: ${event.eventType}`, + }; + } - //////////////// fake - - // await this.datasource.manager.update( - // Booking, - // { id: input.referenceId }, - // { status: "PAID", paymentStatus: "PAID" }, - // ); - // await this.firstMileService.acceptBooking(input.referenceId); - // await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId); - - - //////////////// fake - - //update the booking heer for now the staus anf - const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - - 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, - notify: false, - }); - } - - 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 toLocalStatus( + status: ProviderPaymentStatus, + ): PaymentEntity["status"] { + switch (status) { + case ProviderPaymentStatus.SUCCEEDED: + return "success"; + case ProviderPaymentStatus.FAILED: + return "failed"; + case ProviderPaymentStatus.CANCELLED: + return "canceled"; + case ProviderPaymentStatus.PROCESSING: + return "processing"; + default: + return "action-required"; } + } - /** 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: input.referenceId, - }); - - const method: PaymentEntity["method"] = - PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = - snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); - - const clientAction = (snapshot.clientAction ?? undefined) as - | Record - | undefined; - const data = { - status, - method, - merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", - transactionId: snapshot.providerTxnId ?? existing?.transactionId, - expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt, - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }; - - if (existing) { - await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any); - return { ...existing, ...data, clientAction } as PaymentEntity; - } - - return this.paymentRepo.create({ - 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); - } - - /** - * 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( - (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 reference ${referenceId}: ${message}; using local intent`, - ); - } - - if (!snapshot) { - if (!local) throw new NotFoundException("PaymentIntent not found"); - return this.formatIntentStatus(local); - } - if (!local) throw new NotFoundException("PaymentIntent not found"); - - // Sync local projection with provider-reported status. - const becameSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - - 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: local.id }); - return this.formatIntentStatus(refreshed ?? local); - } - - /** - * 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 = opts.paidAt ?? new Date(); - await this.paymentRepo.update( - { id: intent.id }, - { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, - ); - - if (opts.notify !== false) { - await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); - } - - return { alreadyFinalized: false }; - } - - - async markPaymentFailed(input: { - intentId: string; - failureCode?: string; - failureMessage?: string; - }): Promise { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success" || intent.status === "canceled") return; - - await this.paymentRepo.update( - { 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 { - return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" }); - if (!payment) throw new BadRequestException("No successful payment found for this order"); - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); - - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - return template({ - vendorName: "Ethio Djibouti Railway Freight Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment.method, - subtotal: payment.amount.toString(), - total: payment.amount.toString(), - currency: payment.currency, - reason: payment.reason, - }); - } - - findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id }); - } - - formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { - const clientAction = - intent.clientAction && typeof intent.clientAction === "object" - ? (intent.clientAction as unknown as ClientAction) - : undefined; - return { - intentId: intent.id, - status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, - clientAction, - merchantOrderId: intent.merchantOrderId ?? undefined, - }; - } - - private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { - return { - ...this.formatIntentResponse(intent), - paidAt: intent.paidAt?.toISOString(), - failureCode: intent.failerCode ?? undefined, - failureMessage: intent.failureMessage ?? undefined, - }; - } - - async handlePaymentEvent(event: { - eventType: string; - eventId: string; - referenceId: string; - intentId: string; - providerTxnId?: string; - paidAt?: string; - failureCode?: string; - failureMessage?: string; - }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); - if (event.eventType === "payment.succeeded") { - console.log(`Payment succeeded event received for reference ${event.referenceId}`); - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - console.warn(`No local intent found for reference ${event.referenceId}`); - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - - const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { - providerTxnId: event.providerTxnId, - paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - notify: true, - }); - console.log(`Payment intent ${intent.id} marked as succeeded (alreadyFinalized=${alreadyFinalized})`); - - // The invoice the intent settled is the authority on what was paid for. - // Its `paymentId` links 1:1 to this intent; when its source is a booking, - // `sourceId` holds that booking id — flip the booking itself paid. - const invoice = await this.datasource.manager.findOneBy(Invoice, { - paymentId: intent.id, - }); - console.log(`Invoice lookup for payment intent ${intent.id} returned invoice ${invoice?.id} (source=${invoice?.source}, sourceId=${invoice?.sourceId})`); - if (invoice?.source === Freight.InvoiceSource.Booking) { - console.log(`Marking booking ${invoice.sourceId} as PAID due to invoice ${invoice.id} settlement`); - await this.datasource.manager.update( - Booking, - { id: invoice.sourceId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - - return { processed: true, alreadyFinalized }; - } - - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } - - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; - } - - private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { - switch (status) { - case ProviderPaymentStatus.SUCCEEDED: return "success"; - case ProviderPaymentStatus.FAILED: return "failed"; - case ProviderPaymentStatus.CANCELLED: return "canceled"; - case ProviderPaymentStatus.PROCESSING: return "processing"; - default: return "action-required"; - } - } - - async findByCompanyId(companyId: string) { - return this.paymentRepo.findByCompanyId(companyId); - } + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 67ca68e87..3b86a940c 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum { } export class InitiatePaymentDto { - @ApiProperty({ example: "booking-uuid" }) + @ApiProperty({ example: "invoice-uuid" }) @IsString() - bookingId!: string; + invoiceId!: string; @ApiProperty({ enum: PaymentMethodTypeEnum, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 5cd06091a..9e974b31e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 67dabdf8c..218a20436 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -22,6 +22,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { Freight } from "@edr/types"; +import { BillingService } from "../billing/billing.service"; + + import { BATCH_CRON, BATCH_TIMEZONE, @@ -29,7 +33,7 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_WINDOW_MS, -} from './booking-batch.constants'; +} from "./booking-batch.constants"; import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, @@ -56,12 +60,12 @@ interface RouteDayGroup { type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = - | 'ALLOCATED' - | 'SELECTED_FOR_BATCH' - | 'READY' - | 'WAITING' - | 'PENDING_CONTRACT' - | 'EXPIRED'; + | "ALLOCATED" + | "SELECTED_FOR_BATCH" + | "READY" + | "WAITING" + | "PENDING_CONTRACT" + | "EXPIRED"; export interface BatchBoardBooking { id: string; @@ -76,10 +80,10 @@ export interface BatchBoardBooking { } export type BookingAllocationStatus = - | 'NOT_ATTEMPTED' - | 'ASSIGNED' - | 'DEFERRED' - | 'FAILED'; + | "NOT_ATTEMPTED" + | "ASSIGNED" + | "DEFERRED" + | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { fullyExecutedAt: string | null; @@ -117,9 +121,9 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; - locomotive: BatchBoardSchedule['locomotive']; - capacity: BatchBoardSchedule['capacity']; - counts: BatchBoardSchedule['counts']; + locomotive: BatchBoardSchedule["locomotive"]; + capacity: BatchBoardSchedule["capacity"]; + counts: BatchBoardSchedule["counts"]; windows: BatchWindowGroup[]; pendingContract: BatchWindowGroup; allocationViolations: string[]; @@ -182,7 +186,10 @@ export class BookingBatchService implements OnModuleInit { private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, + private readonly billing: BillingService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, + ) {} /** On boot, reconcile OPEN route-days and re-arm settle timers. */ @@ -199,10 +206,10 @@ export class BookingBatchService implements OnModuleInit { } const reserved = await this.dataSource .getRepository(Booking) - .createQueryBuilder('b') - .select('DISTINCT b.train_schedule_id', 'scheduleId') + .createQueryBuilder("b") + .select("DISTINCT b.train_schedule_id", "scheduleId") .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) - .andWhere('b.train_schedule_id IS NOT NULL') + .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); } @@ -280,7 +287,7 @@ export class BookingBatchService implements OnModuleInit { /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ private async openRouteDayGroups(): Promise { const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, + where: { bookingWindowStatus: "OPEN" }, }); const groups = new Map(); for (const s of open) { @@ -314,25 +321,29 @@ export class BookingBatchService implements OnModuleInit { if (!booking?.trainScheduleId) return; const isBatchPaid = - booking.status === 'SELECTED_FOR_BATCH' || - booking.status === 'AWAITING_PAYMENT' || - booking.status === 'PAID' || - booking.paymentStatus === 'PAID'; + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" || + booking.status === "PAID" || + booking.paymentStatus === "PAID"; if (!isBatchPaid) return; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); - } else if (booking.paymentStatus !== 'PAID') { + .update(bookingId, { paymentStatus: "PAID", status: "PAID" }); + } else if (booking.paymentStatus !== "PAID") { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); + .update(bookingId, { paymentStatus: "PAID" }); } - const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + const linked = + await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { - await this.allocate(booking.trainScheduleId, booking, 'paid'); + await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); @@ -342,7 +353,7 @@ export class BookingBatchService implements OnModuleInit { booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -353,7 +364,11 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, ); } - if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { + if ( + result.issues.some( + (i) => i.bookingId === bookingId && i.status !== "ASSIGNED", + ) + ) { const issue = result.issues.find((i) => i.bookingId === bookingId); this.logger.warn( `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, @@ -368,9 +383,10 @@ export class BookingBatchService implements OnModuleInit { /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { - const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); + const unlinked = + await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); @@ -379,7 +395,7 @@ export class BookingBatchService implements OnModuleInit { // ---- cron entry point ----------------------------------------------------- - @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) + @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -409,7 +425,7 @@ export class BookingBatchService implements OnModuleInit { destinationStation: true, route: true, }, - order: { scheduledDepartureDate: 'ASC' }, + order: { scheduledDepartureDate: "ASC" }, }); const wagonLengths = await this.loadWagonLengths(); @@ -417,7 +433,7 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -429,13 +445,15 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), }; }); @@ -446,11 +464,15 @@ export class BookingBatchService implements OnModuleInit { } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ - async getBatchBoardDetail(scheduleId: string): Promise { - const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { - throw new BadRequestException('Schedule is no longer active'); + async getBatchBoardDetail( + scheduleId: string, + ): Promise { + const s = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!s) + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (s.status === "ARRIVED" || s.status === "CANCELLED") { + throw new BadRequestException("Schedule is no longer active"); } const wagonLengths = await this.loadWagonLengths(); @@ -460,12 +482,18 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< - ReturnType + ReturnType >; try { - allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); + allocationPreview = + await this.trainSchedulingService.previewAllocationForSchedule(s.id); } catch { - allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; + allocationPreview = { + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }; } const allocationByBooking = new Map( allocationPreview.issues.map((i) => [i.bookingId, i]), @@ -478,17 +506,23 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), - fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, - selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, - allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', + fullyExecutedAt: b.fullyExecutedAt + ? b.fullyExecutedAt.toISOString() + : null, + selectedForBatchAt: b.selectedForBatchAt + ? b.selectedForBatchAt.toISOString() + : null, + allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, }; }); @@ -518,11 +552,11 @@ export class BookingBatchService implements OnModuleInit { const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const counts = emptyCounts(); for (const b of bookingsInWindow) { - if (b.state === 'ALLOCATED') counts.allocated += 1; - else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; - else if (b.state === 'READY') counts.ready += 1; - else if (b.state === 'WAITING') counts.waiting += 1; - else if (b.state === 'EXPIRED') counts.expired += 1; + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; @@ -530,7 +564,7 @@ export class BookingBatchService implements OnModuleInit { const windows: BatchWindowGroup[] = []; for (const [key, bucket] of windowBuckets) { - if (key === 'pending-contract' || !bucket.window) continue; + if (key === "pending-contract" || !bucket.window) continue; const w = bucket.window; windows.push({ key: w.key, @@ -543,44 +577,51 @@ export class BookingBatchService implements OnModuleInit { bookings: bucket.items, }); } - windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + windows.sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), + ); - const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; + const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, windows, pendingContract: { - key: 'pending-contract', - label: 'Pending contract', - date: '', - dateLabel: '', - start: '', - end: '', + key: "pending-contract", + label: "Pending contract", + date: "", + dateLabel: "", + start: "", + end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -601,17 +642,21 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, - ): BatchBoardSchedule['capacity'] { - const allocated = items.filter((i) => i.state === 'ALLOCATED'); + ): BatchBoardSchedule["capacity"] { + const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( - (i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', + (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: - Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, + Math.round( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, + ) / 100, maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, + usedWeightTons: + Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / + 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, }; } @@ -627,51 +672,66 @@ export class BookingBatchService implements OnModuleInit { trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: items.slice(0, 3), }; } - private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { - if (linked) return 'ALLOCATED'; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { - return 'SELECTED_FOR_BATCH'; + private boardState( + booking: Booking, + linked: boolean, + ): BatchBoardBookingState { + if (linked) return "ALLOCATED"; + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { + return "SELECTED_FOR_BATCH"; } - if (booking.status === 'EXPIRED') return 'EXPIRED'; - if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; - if (booking.status === 'PAID') return 'WAITING'; - return 'PENDING_CONTRACT'; + if (booking.status === "EXPIRED") return "EXPIRED"; + if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt) + return "READY"; + if (booking.status === "PAID") return "WAITING"; + return "PENDING_CONTRACT"; } // ---- core fill ------------------------------------------------------------ /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${scheduleId} has no locomotive/train set — skipped.`, + ); return; } @@ -681,7 +741,7 @@ export class BookingBatchService implements OnModuleInit { await this.syncScheduleMaxWagons(schedule, locomotive, rules); let budget = await this.remainingCapacity(schedule, limits, wagonLengths); if (budget.wagons <= 0) { - await this.setWindow(scheduleId, 'FULL'); + await this.setWindow(scheduleId, "FULL"); return; } @@ -693,7 +753,12 @@ export class BookingBatchService implements OnModuleInit { if (!this.fits(need, budget)) { if (booking.isGovernment) { - budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); + budget = await this.preemptForGovernment( + scheduleId, + need, + budget, + wagonLengths, + ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { continue; // skip a booking that exceeds weight/length/wagons, try the next @@ -701,7 +766,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(scheduleId, booking, 'gov'); + await this.allocate(scheduleId, booking, "gov"); } else { await this.reserve(booking, scheduleId); armed = true; @@ -710,7 +775,7 @@ export class BookingBatchService implements OnModuleInit { if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); + if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -736,13 +801,14 @@ export class BookingBatchService implements OnModuleInit { const scheduleIds = bookable .filter( (s) => - s.bookingWindowStatus === 'OPEN' && + s.bookingWindowStatus === "OPEN" && s.scheduleDate != null && eatDay(new Date(s.scheduleDate)) === day, ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + new Date(a.scheduleDate).getTime() - + new Date(b.scheduleDate).getTime(), ) .map((s) => s.id); @@ -754,15 +820,22 @@ export class BookingBatchService implements OnModuleInit { // Live per-schedule budget + arm flag, in departure order. const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; for (const id of scheduleIds) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${id} has no locomotive/train set — skipped.`, + ); continue; } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + const budget = await this.remainingCapacity( + schedule, + limits, + wagonLengths, + ); trains.push({ id, budget, armed: false }); } if (trains.length === 0) return []; @@ -783,7 +856,12 @@ export class BookingBatchService implements OnModuleInit { // Government booking fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { - t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + t.budget = await this.preemptForGovernment( + t.id, + need, + t.budget, + wagonLengths, + ); if (this.fits(need, t.budget)) { target = t; break; @@ -798,7 +876,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(target.id, booking, 'gov'); + await this.allocate(target.id, booking, "gov"); } else { await this.reserve(booking, target.id); target.armed = true; @@ -807,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { } for (const t of trains) { - if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } @@ -817,18 +895,20 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); let anySettled = false; for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : false; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); anySettled = true; } else if (expired) { await this.expire(booking); @@ -844,17 +924,19 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : true; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); } else if (expired) { await this.expire(booking); } @@ -866,11 +948,13 @@ export class BookingBatchService implements OnModuleInit { } private triggerWagonAllocation(scheduleId: string): void { - void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => - this.logger.warn( - `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, - ), - ); + void this.trainSchedulingService + .tryAutoWagonAllocation(scheduleId) + .catch((err) => + this.logger.warn( + `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, + ), + ); } // ---- staff override actions ---------------------------------------------- @@ -882,18 +966,20 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.trainScheduleId) { - throw new BadRequestException('Booking has no target schedule to allocate to'); + throw new BadRequestException( + "Booking has no target schedule to allocate to", + ); } await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); - await this.allocate(booking.trainScheduleId, booking, 'paid'); + .update(bookingId, { paymentStatus: "PAID" }); + await this.allocate(booking.trainScheduleId, booking, "paid"); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); } @@ -902,7 +988,10 @@ export class BookingBatchService implements OnModuleInit { * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Used for EXPIRED or full-schedule bookings — no re-approval. */ - async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + async moveToSchedule( + bookingId: string, + newScheduleId: string, + ): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); @@ -911,15 +1000,20 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: newScheduleId } }); - if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); - if (schedule.bookingWindowStatus !== 'OPEN') { - throw new BadRequestException('Target schedule is not accepting bookings'); + if (!schedule) + throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== "OPEN") { + throw new BadRequestException( + "Target schedule is not accepting bookings", + ); } if ( schedule.originStationId !== booking.originYardId || schedule.destinationStationId !== booking.destinationYardId ) { - throw new BadRequestException('Target schedule is not on the booking route'); + throw new BadRequestException( + "Target schedule is not on the booking route", + ); } await this.dataSource.transaction(async (manager) => { @@ -931,15 +1025,15 @@ export class BookingBatchService implements OnModuleInit { ); } const restoredStatus = - booking.status === 'EXPIRED' + booking.status === "EXPIRED" ? booking.isGovernment - ? 'APPROVED' - : 'FULLY_EXECUTED' + ? "APPROVED" + : "FULLY_EXECUTED" : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: 'ELIGIBLE', + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -953,7 +1047,8 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); await this.expire(booking); - if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + if (booking.trainScheduleId) + await this.fillSchedule(booking.trainScheduleId); } // ---- mutations ------------------------------------------------------------ @@ -971,11 +1066,19 @@ export class BookingBatchService implements OnModuleInit { const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, - status: 'SELECTED_FOR_BATCH', + status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; + // The invoice was generated at booking creation/approval, before this pay + // window opened — refresh its printed due date to the real deadline. + await this.billing.syncPayableDueDate( + Freight.InvoiceSource.Booking, + booking.id, + deadline, + "PREPAID", + ); await this.notifier.payNow(booking, deadline); } @@ -983,13 +1086,14 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: 'paid' | 'gov', + reason: "paid" | "gov", ): Promise { await this.dataSource.transaction(async (manager) => { - const exists = await this.trainScheduleBookingsRepository.existsForBooking( - booking.id, - manager, - ); + const exists = + await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); if (!exists) { await this.trainScheduleBookingsRepository.createMany( [{ trainScheduleId: scheduleId, bookingId: booking.id }], @@ -997,8 +1101,8 @@ export class BookingBatchService implements OnModuleInit { ); } await manager.getRepository(Booking).update(booking.id, { - status: reason === 'paid' ? 'PAID' : booking.status, - schedulingStatus: 'SCHEDULED', + status: reason === "paid" ? "PAID" : booking.status, + schedulingStatus: "SCHEDULED", scheduledAt: new Date(), paymentDeadline: null, selectedForBatchAt: null, @@ -1026,12 +1130,16 @@ export class BookingBatchService implements OnModuleInit { private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { trainScheduleId: null, - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // Pay window closed before settlement → expire the booking's open invoice too + // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays + // source-agnostic. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); } @@ -1049,7 +1157,9 @@ export class BookingBatchService implements OnModuleInit { await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); const allocatedCommercial = - await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + await this.bookingsRepository.findAllocatedCommercialForSchedule( + scheduleId, + ); // lowest priority first; reserved are cheaper to free than allocated const candidates = [...reservedCommercial, ...allocatedCommercial].sort( @@ -1066,11 +1176,19 @@ export class BookingBatchService implements OnModuleInit { manager, ); await manager.getRepository(Booking).update(victim.id, { - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); + // Displaced → EXPIRED: close its open invoice too, so a dead booking + // can't still be paid (mirrors `expire()`; enlisted in this txn). + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + victim.id, + "PREPAID", + manager, + ); }); this.notifier.displaced(victim); freed = this.add(freed, this.needFor(victim, wagonLengths)); @@ -1088,7 +1206,10 @@ export class BookingBatchService implements OnModuleInit { (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + return Math.max( + DEFAULT_WAGONS_PER_BOOKING, + fromContainers || DEFAULT_WAGONS_PER_BOOKING, + ); } /** What one booking consumes along all three capacity axes. */ @@ -1175,7 +1296,7 @@ export class BookingBatchService implements OnModuleInit { Array<{ lengthMeters: number; capacityTons: number }> > { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ @@ -1186,17 +1307,23 @@ export class BookingBatchService implements OnModuleInit { private async loadWagonLengths(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); - const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); + const byCode = new Map( + types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), + ); return { - container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: + byCode.get("NW5")?.lengthMeters ?? + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, }; } private async loadGlobalRules(): Promise { - return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + return this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .findOne({ where: {} }); } /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ @@ -1208,7 +1335,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = [...allocated, ...reserved].reduce( (acc, b) => this.add(acc, this.needFor(b, wagonLengths)), { wagons: 0, weightTons: 0, lengthMeters: 0 }, @@ -1221,7 +1350,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + reserved.reduce((s, b) => s + this.wagonsFor(b), 0); @@ -1230,7 +1361,7 @@ export class BookingBatchService implements OnModuleInit { private async setWindow( scheduleId: string, - status: 'OPEN' | 'FULL' | 'CLOSED', + status: "OPEN" | "FULL" | "CLOSED", ): Promise { await this.dataSource .getRepository(TrainSchedule) @@ -1247,7 +1378,9 @@ export class BookingBatchService implements OnModuleInit { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => - this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), ); }, PAYMENT_WINDOW_MS); this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); @@ -1256,7 +1389,7 @@ export class BookingBatchService implements OnModuleInit { private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { - if (this.scheduler.doesExist('timeout', name)) { + if (this.scheduler.doesExist("timeout", name)) { this.scheduler.deleteTimeout(name); } } catch { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 098bfa14f..0f2e22076 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -43,6 +44,7 @@ import { ContractsModule } from '../contracts/contracts.module'; ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), + BillingModule, NotificationsModule, LocomotivesModule, WagonTypesModule, diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts deleted file mode 100644 index 8b14dcea3..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; - -import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; - -export const WAREHOUSE_FEE_TYPES = [ - 'CONTAINER_DEMURRAGE', - 'BULK_DEMURRAGE', - 'STORAGE_FEE', - 'HANDLING_FEE', -] as const; -export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; - -@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) -@Index(['invoiceId']) -export class WarehouseFeeInvoiceItem extends BaseEntity { - @Column({ name: 'invoice_id', type: 'uuid' }) - invoiceId!: string; - - @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'invoice_id' }) - invoice?: WarehouseFeeInvoice; - - @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) - feeRuleId?: string | null; - - @Column({ name: 'fee_type', type: 'varchar', length: 32 }) - feeType!: WarehouseFeeType; - - @Column({ name: 'description', type: 'varchar', length: 255 }) - description!: string; - - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) - quantity!: number; - - @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) - unitRate!: number; - - @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - amount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - @Column({ name: 'chargeable_days', type: 'int', nullable: true }) - chargeableDays?: number | null; - - @Column({ name: 'free_days', type: 'int', nullable: true }) - freeDays?: number | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts deleted file mode 100644 index e57d626d5..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; -export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; - -export const WAREHOUSE_INVOICE_STATUSES = [ - 'DRAFT', - 'ISSUED', - 'PARTIALLY_PAID', - 'PAID', - 'CANCELLED', -] as const; -export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; - -/** A single recorded payment against a warehouse fee invoice (history). */ -export interface WarehouseInvoicePayment { - amount: number; - method?: string | null; - reference?: string | null; - paidAt: string; -} - -/** - * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. - * Owns warehouse fees; links to booking/customer/inventory/location so it can - * connect to the existing payment module without duplicating it. - */ -@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) -@Index(['invoiceNumber'], { unique: true }) -@Index(['bookingId']) -@Index(['inventoryId']) -@Index(['status']) -export class WarehouseFeeInvoice extends BaseEntity { - @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) - invoiceNumber!: string; - - @Column({ name: 'booking_id', type: 'uuid', nullable: true }) - bookingId?: string | null; - - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId?: string | null; - - @Column({ name: 'inventory_id', type: 'uuid' }) - inventoryId!: string; - - @Column({ name: 'facility_id', type: 'uuid', nullable: true }) - facilityId?: string | null; - - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId?: string | null; - - @Column({ name: 'yard_id', type: 'uuid', nullable: true }) - yardId?: string | null; - - @Column({ name: 'zone_id', type: 'uuid', nullable: true }) - zoneId?: string | null; - - @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) - invoiceType!: WarehouseInvoiceType; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) - status!: WarehouseInvoiceStatus; - - @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - subtotalAmount!: number; - - @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - taxAmount!: number; - - @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - totalAmount!: number; - - @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - paidAmount!: number; - - @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - balanceAmount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ - @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) - periodStart?: Date | null; - - @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) - periodEnd?: Date | null; - - @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) - issuedAt?: Date | null; - - @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) - dueDate?: Date | null; - - @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) - paidAt?: Date | null; - - @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) - cancelledAt?: Date | null; - - @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) - payments!: WarehouseInvoicePayment[]; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 815841e54..290b6f0c2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) volume?: number | null; + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; + @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) status!: WarehouseInventoryStatus; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts deleted file mode 100644 index 5b5df396e..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; - -@Injectable() -export class WarehouseFeeInvoiceItemRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts deleted file mode 100644 index 97328f46d..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; - -@Injectable() -export class WarehouseFeeInvoiceRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { - super(repository); - } -} 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 68f536b33..6b2bd8c28 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 @@ -273,6 +273,16 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get(':id/grn-document') + @ApiOperation({ summary: 'View goods received note PDF' }) + async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.grnDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + 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) { 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 618553df3..001897b3f 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 @@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) => LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; +const HANDOVER_DOCUMENT_MARKER = '[Handover Document]'; export interface InventoryInquiryResult { id: string; @@ -249,6 +250,7 @@ export interface ReadyToLoadRow { containerNumber: string | null; cargoType: string | null; weight: number | null; + grnNumber: string | null; origin: string | null; destination: string | null; inspectionStatus: string | null; @@ -295,6 +297,7 @@ export interface ImportUnloadedRow { containerNumber: string | null; cargoType: string | null; weight: number | null; + grnNumber: string | null; trainSchedule: string | null; inspectionStatus: string | null; pickupOption: string; @@ -302,6 +305,8 @@ export interface ImportUnloadedRow { currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; + handoverDocumentReference: string | null; + handoverDocumentDate: string | null; deliveredAt: string | null; } @@ -415,7 +420,10 @@ export class WarehouseInventoryService { const search = filter.search?.trim(); const where: FindManyOptions['where'] = search - ? { ...base, notes: ILike(`%${search}%`) } + ? [ + { ...base, notes: ILike(`%${search}%`) }, + { ...base, grnNumber: ILike(`%${search}%`) }, + ] : base; const items = await this.inventoryRepository.findAll({ @@ -766,6 +774,7 @@ export class WarehouseInventoryService { const [booking] = await manager.query( `SELECT b.reference AS "reference", b.payment_status AS "paymentStatus", + b.freight_type AS "freightType", b.cargo_total_weight_vgm AS "weight", company.name AS "customer", company.tin AS "customerTin", @@ -847,6 +856,12 @@ export class WarehouseInventoryService { const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); if (existing) { skip('Already received'); continue; } + const containerQuantity = Number(booking.containerQuantity ?? 0); + if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { + skip('Container booking has no container quantity'); + continue; + } + const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); const truckEntrance = dto.truckEntrance @@ -867,8 +882,9 @@ export class WarehouseInventoryService { yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - quantity: Number(booking.containerQuantity) || 1, + quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, weight: Number(booking.weight) || 0, + grnNumber, status: 'RECEIVED', arrivedAt: now, notes: receiveNote, @@ -962,6 +978,7 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", + COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", @@ -1021,6 +1038,7 @@ export class WarehouseInventoryService { ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", + COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", CASE WHEN b.last_mile_delivery_address IS NOT NULL @@ -1029,6 +1047,8 @@ export class WarehouseInventoryService { inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", + substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", + substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", oy.country AS "originCountry", dy.country AS "destinationCountry" @@ -1669,6 +1689,7 @@ export class WarehouseInventoryService { quantity, weight, volume: dto.volume ?? null, + grnNumber, status: 'RECEIVED', arrivedAt: now, notes: receiveNote, @@ -1933,24 +1954,31 @@ export class WarehouseInventoryService { ); } - const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); - const reference = dto.reference?.trim() || null; + const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + const releaseDate = isTruckLeaving + ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() + : item.releaseDate ?? null; + const reference = dto.reference?.trim() || (await this.generateReleaseReference(item)); const exitInspectionNote = this.buildExitInspectionNote(dto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, - notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'), + notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); await this.activityLog.record( { activityType: 'INVENTORY_RELEASED', inventoryId: id, warehouseId: item.warehouseId, - description: reference - ? `Release order ${reference} sent to customer` - : 'Release order sent to customer', + description: isTruckLeaving + ? reference + ? `Exit paper ${reference} generated` + : 'Exit paper generated' + : reference + ? `Truck arrival ${reference} registered` + : 'Truck arrival registered', performedBy: dto.performedBy, }, manager, @@ -2039,6 +2067,106 @@ export class WarehouseInventoryService { } /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ + async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const [row] = await this.dataSource.query( + `SELECT inv.id, + COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", + COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt", + inv.quantity, + inv.weight, + inv.volume, + inv.status, + inv.notes, + b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + b.cargo_total_weight_vgm AS "bookingDeclaredWeight", + company.name AS "customerName", + company.tin AS "customerTin", + service_type.service_name AS "serviceType", + origin_yard.label AS "originYardLabel", + origin_yard.code AS "originYardCode", + destination_yard.label AS "destinationYardLabel", + destination_yard.code AS "destinationYardCode", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + booking_container."containerSummary" AS "bookingContainerSummary", + 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" + 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.service_types service_type ON service_type.id = b.service_type_id + LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id + LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_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 LATERAL ( + SELECT MIN(bc.container_number) AS container_number, + STRING_AGG( + CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')), + ', ' + ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text) + ) AS "containerSummary" + FROM freight.booking_container bc + LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + ) booking_container ON true + 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) + 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.grnNumber) { + throw new BadRequestException('GRN number is missing for this inventory item'); + } + + const html = this.buildGrnDocumentHtml({ + grnNumber: row.grnNumber, + receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(), + bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A', + bookingStatus: row.bookingStatus ?? null, + customerName: row.customerName ?? null, + customerTin: row.customerTin ?? null, + serviceType: row.serviceType ?? null, + freightType: row.freightType ?? null, + tradeDirection: row.tradeDirection ?? null, + route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode] + .filter(Boolean) + .join(' to ') || null, + containerNumber: row.containerNumber ?? null, + bookingContainerSummary: row.bookingContainerSummary ?? null, + cargoDescription: row.cargoDescription ?? null, + quantity: Number(row.quantity ?? 0), + weight: Number(row.weight ?? 0), + volume: row.volume == null ? null : Number(row.volume), + bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 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, + receiveSummary: this.extractReceiveSummary(row.notes), + }); + + return { + filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + }; + } + async approveDeliveryForBooking( bookingId: string, userId?: string, @@ -2121,8 +2249,17 @@ export class WarehouseInventoryService { b.status AS "bookingStatus", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + b.scheduled_date AS "scheduledDate", + b.cargo_total_weight_vgm AS "bookingDeclaredWeight", + b.last_mile_delivery_address AS "lastMileDeliveryAddress", company.name AS "customerName", + service_type.service_name AS "serviceType", + origin_yard.label AS "originYardLabel", + origin_yard.code AS "originYardCode", + destination_yard.label AS "destinationYardLabel", + destination_yard.code AS "destinationYardCode", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + booking_container."containerSummary" AS "bookingContainerSummary", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -2134,14 +2271,25 @@ export class WarehouseInventoryService { 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.service_types service_type ON service_type.id = b.service_type_id + LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id + LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_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 LATERAL ( + SELECT MIN(bc.container_number) AS container_number, + STRING_AGG( + CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')), + ', ' + ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text) + ) AS "containerSummary" + FROM freight.booking_container bc + LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + ) booking_container ON true 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 @@ -2158,18 +2306,37 @@ export class WarehouseInventoryService { } const bookingReference = row.bookingReference || row.bookingId || 'N/A'; + const reference = + this.extractHandoverDocumentLine(row.notes, 'Handover Reference') || + `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`; + const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At'); + const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date(); + const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt; + if (!generatedAtValue) { + await this.inventoryRepository.update(id, { + notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)), + }); + } + const html = this.buildHandoverDocumentHtml({ - reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`, - handedOverAt: new Date(row.handoverDate ?? Date.now()), + reference, + handedOverAt, bookingReference, bookingStatus: row.bookingStatus ?? null, customerName: row.customerName ?? null, + serviceType: row.serviceType ?? null, freightType: row.freightType ?? null, tradeDirection: row.tradeDirection ?? null, + route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode] + .filter(Boolean) + .join(' to ') || null, + scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null, containerNumber: row.containerNumber ?? null, + bookingContainerSummary: row.bookingContainerSummary ?? null, cargoDescription: row.cargoDescription ?? null, quantity: Number(row.quantity ?? 0), weight: Number(row.weight ?? 0), + bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 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, @@ -2178,11 +2345,12 @@ export class WarehouseInventoryService { releaseOrderReference: row.releaseOrderReference ?? null, releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, trainSchedule: row.trainSchedule ?? null, + lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null, customerApproval: this.extractCustomerDeliveryApproval(row.notes), }); return { - filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer: await this.releaseDocuments.htmlToPdfBuffer(html), }; } @@ -2666,6 +2834,128 @@ export class WarehouseInventoryService { return this.findById(id); } + private buildGrnDocumentHtml(data: { + grnNumber: string; + receivedAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + customerTin: string | null; + serviceType: string | null; + freightType: string | null; + tradeDirection: string | null; + route: string | null; + containerNumber: string | null; + bookingContainerSummary: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + volume: number | null; + bookingDeclaredWeight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + receiveSummary: string | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const receivedAt = data.receivedAt.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const rows: Array<[string, unknown]> = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Customer TIN', data.customerTin], + ['Booking Status', data.bookingStatus], + ['Service Type', data.serviceType], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Route', data.route], + ['Container Number', data.containerNumber], + ['Booking Containers', data.bookingContainerSummary], + ['Cargo / Goods Description', data.cargoDescription], + ['Quantity', data.quantity], + ['Received Weight', `${data.weight.toLocaleString()} kg`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Volume', data.volume == null ? null : data.volume.toLocaleString()], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory Status', data.inventoryStatus], + ...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []), + ]; + + return ` + + + + Goods Received Note + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Goods Received Note

+
Warehouse receiving confirmation
+
+
+ GRN Number + ${esc(data.grnNumber)} + Received: ${esc(receivedAt)} +
+
+
+
+ This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location. +
+
Receiving Particulars
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
Receipt Clause
+
+ This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals. +
+
+
Warehouse receiver name / signature / date
+
Driver or customer representative name / signature / date
+
+ +`; + } + private buildReleaseDocumentHtml(data: { reference: string; issuedAt: Date; @@ -2721,7 +3011,7 @@ export class WarehouseInventoryService { - Warehouse Gate Clearance / Release Order + Warehouse Release / Exit Paper - - -
-
-
-
Ethio-Djibouti Railway S.C.
-

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

-
-
- Document no. - ${esc(invoice.invoiceNumber)} - Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} -
-
-
${esc(sealText)}
-
-
Status${esc(invoice.status.replace(/_/g, ' '))}
-
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking reference${esc(details.bookingReference)}
-
Customer${esc(details.customerName)}
-
Inventory reference${esc(details.inventoryReference)}
-
Inventory info${esc(details.inventoryInfo)}
-
Clearance${esc(details.clearanceStatus)}
-
Warehouse${esc(details.warehouseName)}
-
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
-
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
-
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
-
- - - - - - - - - - - - ${items - .map( - (item) => ` - - - - - - `, - ) - .join('')} - -
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
-
-
Subtotal${esc(money(invoice.subtotalAmount))}
-
Tax${esc(money(invoice.taxAmount))}
-
Total${esc(money(invoice.totalAmount))}
-
Paid${esc(money(invoice.paidAmount))}
-
Balance${esc(money(invoice.balanceAmount))}
-
- -
- -`; - } - - private safeFilename(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts new file mode 100644 index 000000000..e201241ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -0,0 +1,88 @@ +/** + * Public shapes for warehouse fee invoices. + * + * Warehouse fee invoices are no longer a standalone table — they are global + * `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the + * central {@link BillingService}. These types preserve the warehouse-facing API + * contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines + + * inventory context) back into the historical `WarehouseFeeInvoice` JSON so the + * portal/backoffice stay untouched. + */ + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** A billed warehouse fee line, projected from a global `InvoiceLine`. */ +export interface WarehouseInvoiceItemView { + feeRuleId: string | null; + feeType: WarehouseFeeType; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + chargeableDays: number | null; + freeDays: number | null; +} + +/** + * The warehouse-facing invoice header — same field set the old + * `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The + * typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the + * charge `period` are derived from the linked inventory item; `customerId` is the + * billed company; `invoiceType` is the invoice `type`. + */ +export interface WarehouseFeeInvoiceView { + id: string; + invoiceNumber: string; + bookingId: string | null; + customerId: string | null; + inventoryId: string; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + invoiceType: WarehouseInvoiceType; + status: WarehouseInvoiceStatus; + subtotalAmount: number; + taxAmount: number; + totalAmount: number; + paidAmount: number; + balanceAmount: number; + currency: string; + periodStart: Date | null; + periodEnd: Date | null; + issuedAt: Date | null; + dueDate: Date | null; + paidAt: Date | null; + cancelledAt: Date | null; + payments: WarehouseInvoicePayment[]; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index a77a46c29..f8c0dd355 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,101 +1,23 @@ -import { existsSync } from 'fs'; +import { Injectable } from '@nestjs/common'; -import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; const MIN_VALID_PDF_BYTES = 2_000; -const RELEASE_DOCUMENT_PRINT_STYLES = ` -`; - @Injectable() export class WarehouseReleaseDocumentService { - private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + constructor(private readonly pdf: PdfRenderService) {} - async htmlToPdfBuffer(html: string): Promise { - const preparedHtml = this.injectPdfPrintStyles(html); - const executablePath = this.resolveExecutablePath(); - - try { - const puppeteer = await import('puppeteer'); - const launchOptions: import('puppeteer').LaunchOptions = { - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - ...(executablePath ? { executablePath } : {}), - }; - - const browser = await puppeteer.default.launch(launchOptions); - try { - const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); - await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); - await page.emulateMediaType('print'); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const pdf = await page.pdf({ - format: 'A4', - printBackground: true, - margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, - }); - - const buffer = Buffer.from(pdf); - if (!this.isValidPdf(buffer)) { - throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); - } - this.logger.log( - `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, - ); - return buffer; - } finally { - await browser.close(); - } - } catch (error) { - this.logger.error( - `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, - ); - const fallback = this.htmlToBasicPdfBuffer(preparedHtml); - if (this.isValidPdf(fallback)) { - this.logger.warn( - `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, - ); - return fallback; - } - throw new InternalServerErrorException( - 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', - ); - } - } - - private injectPdfPrintStyles(html: string): string { - if (html.includes('warehouse-release-document-print-fix')) return html; - if (html.includes('')) { - return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); - } - return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; - } - - private resolveExecutablePath(): string | undefined { - const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); - if (fromEnv && existsSync(fromEnv)) return fromEnv; - - const candidates = [ - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/bin/google-chrome-stable', - '/usr/bin/google-chrome', - ]; - return candidates.find((path) => existsSync(path)); - } - - private isValidPdf(buffer: Buffer): boolean { - return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + /** + * Render the gate-clearance release document to PDF via the shared renderer, + * falling back to the release-specific hand-built layout when Chromium is + * unavailable. + */ + htmlToPdfBuffer(html: string): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label: 'Warehouse release', + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml), + }); } private htmlToBasicPdfBuffer(html: string): Buffer { 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 d880a3554..b871d2a36 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; @@ -10,8 +12,6 @@ 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'; -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseInvoiceController } from './warehouse-invoice.controller'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseRulesController } from './warehouse-rules.controller'; @@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, - WarehouseFeeInvoice, - WarehouseFeeInvoiceItem, ]), + BillingModule, + DocumentsModule, FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), @@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionRepository, WarehouseAllocationRuleRepository, WarehouseFeeRuleRepository, - WarehouseFeeInvoiceRepository, - WarehouseFeeInvoiceItemRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, diff --git a/apps/edr-freight-api/src/scripts/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts new file mode 100644 index 000000000..810945bbb --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/index.ts @@ -0,0 +1,12 @@ +import type Vorpal from "vorpal"; +import type { CommandContext } from "./types"; + +import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; +import { registerSeedTestSchedules } from "./seed-test-schedules.cmd"; +import { registerSeedTestCompany } from "./seed-test-company.cmd"; + +export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { + registerSeedTestContracts(vorpal, ctx); + registerSeedTestSchedules(vorpal, ctx); + registerSeedTestCompany(vorpal, ctx); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts new file mode 100644 index 000000000..bef4ed031 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts @@ -0,0 +1,85 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; + +export function registerSeedTestCompany( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-company", "Generate a test company with approved importer/exporter profiles and an external user") + .option("--name ", "Company name (default: Test Company)") + .option("--email ", "Company email (default: company@test.com)") + .option("--tin ", "Tax ID (default: auto-generated TSTxxxxx)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const raw = await ds.query( + `SELECT "tin" FROM "freight"."companies" WHERE "tin" LIKE 'TST%' AND "deleted_at" IS NULL ORDER BY "tin" DESC LIMIT 1`, + ); + let nextTinNum = 1; + if (raw.length > 0) { + const num = parseInt((raw[0] as any).tin.replace("TST", ""), 10); + if (!isNaN(num)) nextTinNum = num + 1; + } + + const name = args.options?.name ?? "Test Company"; + const email = args.options?.email ?? "company@test.com"; + const tin = args.options?.tin ?? `TST${String(nextTinNum).padStart(6, "0")}`; + const userId = `ffffffff-0000-4000-8000-${String(nextTinNum).padStart(12, "0")}`; + + const existing = await ds.getRepository(Company).findOne({ where: { tin } }); + if (existing) { + this.log(`Company with TIN ${tin} already exists (${existing.name})`); + return; + } + + const company = await ds.getRepository(Company).save( + ds.getRepository(Company).create({ + name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email, + phone: "+251911000000", + address: "Test Address", + }), + ); + this.log(` Created company: ${company.name} (TIN: ${tin})`); + + for (const type of [ProfileType.importer, ProfileType.exporter]) { + await ds.getRepository(CompanyProfile).save( + ds.getRepository(CompanyProfile).create({ + companyId: company.id, + type, + reference: `TST-${type.toUpperCase()}-${String(nextTinNum).padStart(3, "0")}`, + status: ProfileStatus.Active, + }), + ); + this.log(` Created ${type} profile (approved)`); + } + + await ds.getRepository(ExternalProfile).save( + ds.getRepository(ExternalProfile).create({ + userId, + companyId: company.id, + firstName: "Test", + lastName: "User", + isPrimaryContact: true, + activeProfileType: ProfileType.importer, + onboardingCompleted: true, + onboardingStep: "done", + }), + ); + this.log(` Created external profile: Test User (userId: ${userId})`); + + this.log(`\nDone — login with email "${email}" and password "password"`); + }); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts new file mode 100644 index 000000000..a46a9dee8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts @@ -0,0 +1,330 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import type { CommandContext } from "./types"; +import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; +import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity"; +import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity"; +import { Rate } from "../../modules/rule-engine/entities/rate.entity"; +import { Contract } from "../../modules/contracts/entities/contract.entity"; +import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity"; +import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity"; +import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity"; + +export function registerSeedTestContracts( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-contracts", "Generate test contracts with companies and all deps") + .option("-n, --count ", "Number of contracts to create (default: 4)") + .option("--status ", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)") + .option("--freight ", "Freight types: CONTAINER,BULK (default: both)") + .option("--direction ", "Trade directions: IMPORT,EXPORT (default: both)") + .option("--company ", "Only create contracts for company matching name/TIN") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10))); + const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE") + .split(",").map((s: string) => s.trim()).filter(Boolean); + const freightList = (args.options?.freight ?? "CONTAINER,BULK") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "CONTAINER" || s === "BULK"); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const companyFilter = args.options?.company as string | undefined; + + if (freightList.length === 0 || directionList.length === 0) { + this.log("error: at least one freight type and trade direction required"); + return; + } + + this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`); + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (!djibouti || !addis) { + this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded"); + return; + } + + const serviceTypes = await ds + .getRepository(ServiceType) + .find({ where: { isActive: true } }); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + if (!railContainer && !railBulk) { + this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded"); + return; + } + + const cargoTypes = await ds + .getRepository(CargoType) + .find({ where: { isActive: true } }); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + const grain = cargoByCode.get("GRAIN"); + const sugar = cargoByCode.get("SUGAR"); + const fertilizer = cargoByCode.get("FERTILIZER"); + + const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } }); + + const companyRepo = ds.getRepository(Company); + let companies = await companyRepo.find({}); + + if (companyFilter) { + companies = companies.filter( + (c) => + c.name.toLowerCase().includes(companyFilter.toLowerCase()) || + c.tin.includes(companyFilter), + ); + } + + if (companies.length === 0) { + this.log("No existing companies found — seeding test companies..."); + companies = await seedTestCompanies(ds, (msg) => this.log(msg)); + } else { + this.log(`Using ${companies.length} existing companies from DB`); + } + + const contractRepo = ds.getRepository(Contract); + + const maxRaw = await ds.query( + `SELECT "reference" FROM "freight"."contracts" WHERE "reference" LIKE 'TST-CTR-%' AND "deleted_at" IS NULL ORDER BY "reference" DESC LIMIT 1`, + ); + let nextRef = 1; + if (maxRaw.length > 0) { + const num = parseInt(maxRaw[0].reference.replace("TST-CTR-", ""), 10); + if (!isNaN(num)) nextRef = num + 1; + } + + for (let i = 0; i < count; i++) { + const statusIdx = i % statusList.length; + const ftIdx = i % freightList.length; + const dirIdx = i % directionList.length; + const companyIdx = i % companies.length; + + const status = statusList[statusIdx]; + const freightType = freightList[ftIdx]; + const direction = directionList[dirIdx]; + const company = companies[companyIdx]; + + const profile = await ds.getRepository(CompanyProfile).findOne({ + where: { + companyId: company.id, + type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter, + }, + }); + if (!profile) continue; + + const ref = `TST-CTR-${String(nextRef + i).padStart(5, "0")}`; + + const serviceTypeId = + freightType === "BULK" && railBulk + ? railBulk.id + : railContainer + ? railContainer.id + : serviceTypes[0].id; + + const originId = direction === "IMPORT" ? djibouti.id : addis.id; + const destId = direction === "IMPORT" ? addis.id : djibouti.id; + + const contract = contractRepo.create({ + reference: ref, + companyId: company.id, + companyProfileId: profile.id, + contractKind: "ONE_TIME" as const, + tradeDirection: direction, + freightType, + serviceTypeId, + paymentCurrency: "USD", + customsClearingEnabled: false, + equipmentReturn: "without_return", + status, + versionNumber: 1, + }); + + const saved = await contractRepo.save(contract); + + await ds.getRepository(ContractRoute).save( + ds.getRepository(ContractRoute).create({ + contractId: saved.id, + originYardId: originId, + destinationYardId: destId, + sortOrder: 1, + }), + ); + + if (freightType === "CONTAINER") { + for (const size of ["20FT", "40FT"] as const) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + containerSize: size, + }), + ); + } + } else { + const bulkCargo = grain || sugar || fertilizer; + if (bulkCargo) { + await ds.getRepository(ContractCargoScope).save( + ds.getRepository(ContractCargoScope).create({ + contractId: saved.id, + cargoTypeId: bulkCargo.id, + quantityCap: 10000, + }), + ); + } + } + + const matchingRates = rates.filter((r) => { + if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false; + if (r.appliesTo === "BULK" && freightType !== "BULK") return false; + if (r.tradeDirection && r.tradeDirection !== direction) return false; + return r.status === "LIVE" && r.trigger === "ALWAYS"; + }); + + const seen = new Set(); + for (const rate of matchingRates.slice(0, 3)) { + const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`; + if (seen.has(sig)) continue; + seen.add(sig); + + await ds.getRepository(ContractRateSnapshot).save( + ds.getRepository(ContractRateSnapshot).create({ + contractId: saved.id, + rateId: rate.id, + rateCode: rate.rateType, + unitPrice: Number(rate.rateValue), + unitOfMeasure: rate.rateUnit, + currency: rate.currency ?? "USD", + containerSize: freightType === "CONTAINER" ? "20FT" : null, + isSurcharge: rate.trigger !== "ALWAYS", + conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null, + }), + ); + } + + this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`); + } + + this.log(`Done — ${count} new contracts created`); + }); +} + +interface CompanySeed { + name: string; + tin: string; + profiles: Array<{ type: ProfileType; reference: string }>; + externalProfile: { userId: string; firstName: string; lastName: string }; +} + +const TEST_COMPANIES: CompanySeed[] = [ + { + name: "Test Importer Co.", tin: "TST000001", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-001" }, + { type: ProfileType.exporter, reference: "TST-EX-001" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, + }, + { + name: "Test Exporter Ltd.", tin: "TST000002", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-002" }, + { type: ProfileType.exporter, reference: "TST-EX-002" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, + }, + { + name: "Bulk Commodities PLC", tin: "TST000003", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-003" }, + { type: ProfileType.exporter, reference: "TST-EX-003" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, + }, + { + name: "Hazardous Logistics Inc.", tin: "TST000004", + profiles: [ + { type: ProfileType.importer, reference: "TST-IM-004" }, + { type: ProfileType.exporter, reference: "TST-EX-004" }, + ], + externalProfile: { userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, + }, +]; + +async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise { + const companyRepo = ds.getRepository(Company); + const profileRepo = ds.getRepository(CompanyProfile); + const extProfileRepo = ds.getRepository(ExternalProfile); + const result: Company[] = []; + + for (const seed of TEST_COMPANIES) { + let company = await companyRepo.findOne({ where: { tin: seed.tin } }); + if (!company) { + company = await companyRepo.save( + companyRepo.create({ + name: seed.name, + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: seed.tin, + country: "Ethiopia", + nationality: CompanyNationality.Ethiopian, + email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`, + phone: "+251911000001", + }), + ); + log(` Created company: ${seed.name}`); + } else { + log(` Company already exists: ${seed.name}`); + } + + for (const p of seed.profiles) { + const existing = await profileRepo.findOne({ + where: { companyId: company.id, type: p.type }, + }); + if (!existing) { + await profileRepo.save( + profileRepo.create({ + companyId: company.id, + type: p.type, + reference: p.reference, + status: ProfileStatus.Active, + }), + ); + log(` Created ${p.type} profile: ${p.reference}`); + } + } + + const ext = seed.externalProfile; + const existingExt = await extProfileRepo.findOne({ + where: { companyId: company.id, userId: ext.userId }, + }); + if (!existingExt) { + await extProfileRepo.save( + extProfileRepo.create({ + userId: ext.userId, + companyId: company.id, + firstName: ext.firstName, + lastName: ext.lastName, + isPrimaryContact: true, + onboardingCompleted: true, + }), + ); + log(` Created external profile: ${ext.firstName} ${ext.lastName}`); + } + + result.push(company); + } + + return result; +} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts new file mode 100644 index 000000000..eeab0e2dd --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts @@ -0,0 +1,243 @@ +import type Vorpal from "vorpal"; +import { DataSource } from "typeorm"; +import { WagonStatus } from "@edr/types"; +import type { CommandContext } from "./types"; +import { Yard } from "../../modules/rule-engine/entities/yard.entity"; +import { Route } from "../../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity"; +import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity"; +import { Wagon } from "../../modules/wagons/entities/wagon.entity"; +import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity"; +import { TrainSet } from "../../modules/train-sets/entities/train-set.entity"; +import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity"; +import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity"; +import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity"; + +async function nextSequence(ds: DataSource, pattern: string): Promise { + const like = pattern.replace(/\*/g, "%"); + const raw = await ds.query( + `SELECT "train_number" FROM "freight"."train_schedules" WHERE "train_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "train_number" DESC LIMIT 1`, + [like.replace(/%/g, "") + "%"], + ); + if (raw.length === 0) return 1; + const ref: string = raw[0].train_number; + const num = parseInt(ref.replace(pattern.split("*")[0], ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +async function nextRouteSeq(ds: DataSource, prefix: string): Promise { + const raw = await ds.query( + `SELECT "name" FROM "freight"."routes" WHERE "name" LIKE $1 AND "deleted_at" IS NULL ORDER BY "name" DESC LIMIT 1`, + [prefix + "%"], + ); + if (raw.length === 0) return 1; + const num = parseInt(raw[0].name.replace(prefix, ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +async function nextWagonSeq(ds: DataSource, prefix: string): Promise { + const raw = await ds.query( + `SELECT "wagon_number" FROM "freight"."wagons" WHERE "wagon_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "wagon_number" DESC LIMIT 1`, + [prefix + "%"], + ); + if (raw.length === 0) return 1; + const num = parseInt(raw[0].wagon_number.replace(prefix, ""), 10); + return isNaN(num) ? 1 : num + 1; +} + +export function registerSeedTestSchedules( + vorpal: Vorpal, + ctx: CommandContext, +): void { + vorpal + .command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking") + .option("-n, --count ", "Number of schedules to create (default: 3)") + .option("--direction ", "IMPORT,EXPORT (default: both)") + .option("--status ", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)") + .option("--days-ahead ", "Days from now for departure (default: 3)") + .action(async function (this: any, args: any) { + const { app } = ctx; + const ds = app.get(DataSource); + + const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10))); + const directionList = (args.options?.direction ?? "IMPORT,EXPORT") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "IMPORT" || s === "EXPORT"); + const statusList = (args.options?.status ?? "SCHEDULED") + .split(",").map((s: string) => s.toUpperCase().trim()) + .filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED"); + const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10)); + + if (directionList.length === 0 || statusList.length === 0) { + this.log("error: at least one direction and status required"); + return; + } + + const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); + const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y])); + const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti"); + const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia"); + + if (!djibouti || !addis) { + this.log("error: need at least one Djibouti and one Ethiopia yard"); + return; + } + + const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } }); + if (wagonTypes.length === 0) { + this.log("error: no wagon types found — seed reference data first"); + return; + } + + const wagonType = wagonTypes[0]; + const wagonCapacity = Number(wagonType.capacityTons) || 70; + const wagonLength = Number(wagonType.lengthMeters) || 14; + const tareWeight = Number(wagonType.tareWeightTons) || 14; + + const locomotiveRepo = ds.getRepository(Locomotive); + const scheduleRepo = ds.getRepository(TrainSchedule); + const trainSetRepo = ds.getRepository(TrainSet); + const wagonRepo = ds.getRepository(Wagon); + const routeRepo = ds.getRepository(Route); + const milestoneRepo = ds.getRepository(RouteMilestone); + + let nextTrainNum = await nextSequence(ds, "TST-SCH-*"); + const routePrefix = "TST-RTE-"; + let nextRouteNum = await nextRouteSeq(ds, routePrefix); + + const now = new Date(); + const travelHours = 11; + const intermediateYards = yards.filter( + (y) => y.id !== djibouti.id && y.id !== addis.id, + ); + + let loco = await locomotiveRepo.findOne({ where: { code: "TST-LOCO-01" } }); + if (!loco) { + loco = await locomotiveRepo.save( + locomotiveRepo.create({ + code: "TST-LOCO-01", + name: "Test Locomotive", + locomotiveType: "DIESEL", + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + currentYardId: djibouti.id, + }), + ); + } + + for (let i = 0; i < count; i++) { + const seq = nextTrainNum + i; + const trainNumber = `TST-SCH-${String(seq).padStart(5, "0")}`; + const dir = directionList[i % directionList.length]; + const status = statusList[i % statusList.length]; + const isDispatched = status === "DISPATCHED"; + const originYard = dir === "IMPORT" ? djibouti : addis; + const destYard = dir === "IMPORT" ? addis : djibouti; + const routeName = `${routePrefix}${String(nextRouteNum + i).padStart(3, "0")}`; + + const departure = new Date(now); + departure.setDate(departure.getDate() + daysAhead + i); + departure.setHours(7, 0, 0, 0); + const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000); + + const route = await routeRepo.save( + routeRepo.create({ + name: routeName, + originYardId: originYard.id, + destinationYardId: destYard.id, + isActive: true, + }), + ); + + await milestoneRepo.save( + milestoneRepo.create({ routeId: route.id, yardId: originYard.id, sequenceNo: 1 }), + ); + for (const [mi, y] of intermediateYards.entries()) { + await milestoneRepo.save( + milestoneRepo.create({ routeId: route.id, yardId: y.id, sequenceNo: (mi + 1) * 2 }), + ); + } + await milestoneRepo.save( + milestoneRepo.create({ + routeId: route.id, + yardId: destYard.id, + sequenceNo: (intermediateYards.length + 1) * 2, + }), + ); + + const totalWagonWeight = 4 * (tareWeight + 20); + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: totalWagonWeight, + totalLengthMeters: wagonLength * 4, + wagonCount: 4, + status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED", + }), + ); + + await ds.getRepository(TrainSetLocomotive).save( + ds.getRepository(TrainSetLocomotive).create({ + trainSetId: trainSet.id, + locomotiveId: loco.id, + sequenceNo: 0, + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: originYard.id, + destinationStationId: destYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isDispatched ? departure : null, + status, + trainNumber, + direction: dir, + maxWagons: 53, + bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN", + }), + ); + + const wagonPrefix = `${trainNumber}-W`; + let nextWagon = await nextWagonSeq(ds, wagonPrefix); + for (let w = 0; w < 4; w++) { + const ws = nextWagon + w; + const wagonNumber = `${wagonPrefix}${String(ws).padStart(2, "0")}`; + + const wagon = wagonRepo.create({ + wagonNumber, + wagonTypeId: wagonType.id, + currentYardId: originYard.id, + currentTrainScheduleId: schedule.id, + tareWeight, + maxPayloadWeight: wagonCapacity, + status: isDispatched ? WagonStatus.Assigned : WagonStatus.Available, + notes: "Test seed wagon", + }); + const saved = await wagonRepo.save(wagon as any); + const physicalWagon = Array.isArray(saved) ? saved[0] : saved; + + await ds.getRepository(TrainSetWagon).save( + ds.getRepository(TrainSetWagon).create({ + trainSetId: trainSet.id, + wagonTypeId: wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: w + 1, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: 20, + status: isDispatched ? "DEPARTED" : "PLANNED", + }), + ); + } + + this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`); + } + + this.log(`Done — ${count} new train schedules created`); + }); +} diff --git a/apps/edr-freight-api/src/scripts/cmds/types.ts b/apps/edr-freight-api/src/scripts/cmds/types.ts new file mode 100644 index 000000000..6c805e07d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/cmds/types.ts @@ -0,0 +1,5 @@ +import type { INestApplicationContext } from "@nestjs/common"; + +export type CommandContext = { + app: INestApplicationContext; +}; diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts new file mode 100644 index 000000000..94b26347f --- /dev/null +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -0,0 +1,36 @@ +import "reflect-metadata"; +import { config } from "dotenv"; + +config(); + +import Vorpal from "vorpal"; +import { registerCommands } from "./cmds/index"; + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "../app.module"; + +const vorpal = new Vorpal(); + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + + try { + registerCommands(vorpal, { app }); + + const args = process.argv.slice(2); + if (args.length > 0) { + await vorpal.exec(args.join(" ")); + } else { + vorpal.parse(process.argv); + } + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error("Script failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts new file mode 100644 index 000000000..367b79b44 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts @@ -0,0 +1,142 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +import { AppModule } from '../app.module'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.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'; + +const BOOKING_REFS = [ + 'WH-EXP-RCV-001', + 'WH-EXP-RCV-002', + 'WH-EXP-RCV-003', + 'WH-EXP-RCV-004', + 'WH-EXP-RCV-005', +]; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const dataSource = app.get(DataSource); + const yardRepo = dataSource.getRepository(Yard); + const serviceTypeRepo = dataSource.getRepository(ServiceType); + const cargoTypeRepo = dataSource.getRepository(CargoType); + const containerTypeRepo = dataSource.getRepository(ContainerType); + const bookingRepo = dataSource.getRepository(Booking); + const bookingContainerRepo = dataSource.getRepository(BookingContainer); + const inventoryRepo = dataSource.getRepository(WarehouseInventory); + + const originYard = + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destinationYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER', includesFirstMile: false, isActive: true } })) ?? + (await serviceTypeRepo.findOne({ where: { includesFirstMile: false, isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT', isActive: true } })) ?? + (await containerTypeRepo.findOne({ where: { code: '40', isActive: true } })) ?? + (await containerTypeRepo.findOne({ where: { sizeFt: 40, isActive: true } })) ?? + (await containerTypeRepo.findOne({ where: { isActive: true } })); + + const missing = [ + !originYard ? 'MOJO/Ethiopia origin yard' : '', + !destinationYard ? 'DJIB_PORT/Djibouti destination yard' : '', + !serviceType ? 'active service type without first mile' : '', + !containerType ? 'active container type' : '', + ].filter(Boolean); + + if (missing.length) { + throw new Error(`Cannot seed warehouse export receive-ready bookings, missing: ${missing.join(', ')}`); + } + + let created = 0; + let skipped = 0; + const now = Date.now(); + + for (const [index, reference] of BOOKING_REFS.entries()) { + const existing = await bookingRepo.findOne({ where: { reference } }); + if (existing) { + skipped += 1; + continue; + } + + const containerQuantity = index === 4 ? 2 : 1; + const weightKg = 18_000 + index * 1_250 + (containerQuantity - 1) * 9_000; + const scheduledDate = new Date(now + index * 60 * 60_000); + + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'PAID', + paymentStatus: 'PAID', + scheduledDate, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `Warehouse export receive-ready cargo ${index + 1}`, + cargoTotalWeightVgm: weightKg, + schedulingStatus: 'NOT_SCHEDULED', + }), + ); + + await bookingContainerRepo.save( + bookingContainerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType!.id, + containerNumber: `EDRU${String(730100 + index).padStart(6, '0')}`, + containerSize: containerType!.sizeFt ? `${containerType!.sizeFt}ft` : containerType!.code, + quantity: containerQuantity, + hazardousQuantity: 0, + reeferQuantity: 0, + vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), + totalVgmTons: Number((weightKg / 1000).toFixed(3)), + wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)), + isOverweight: false, + }), + ); + + const inventory = await inventoryRepo.findOne({ where: { bookingId: booking.id } }); + if (inventory) { + throw new Error(`Seed invariant failed: booking ${reference} unexpectedly has warehouse inventory`); + } + + created += 1; + } + + console.log(`Warehouse export receive-ready seed complete. Created ${created}, skipped ${skipped}.`); + console.log(`Booking refs: ${BOOKING_REFS.join(', ')}`); + console.log('Open Backoffice Warehouse > Receive for loading > Export / Receive to Warehouse.'); + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error('Warehouse export receive-ready seed failed:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index 467c474ee..52598cb95 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, "module": "node16", "moduleResolution": "node16" }, diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 93caae0a4..48b9bc0a9 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183", + "dev": "vite --port 5183 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index fe2586d24..567ecf074 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutDashboard, LayoutGrid, + MapPin, Network, Package, PackageCheck, @@ -70,6 +71,12 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; +import FuelStatsPage from "./pages/fleet/FuelStatsPage"; +import { MaintenancePage } from "./pages/fleet/MaintenancePage"; +import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; +import { FleetDashboard } from "./pages/fleet/FleetDashboard"; +import { TrackingPage } from "./pages/fleet/TrackingPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -193,6 +200,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Fleet Management", items: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Routes", href: "/dashboard/routes", @@ -229,6 +242,36 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -810,6 +853,54 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> ) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for freight bookings. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function ContainerAllocationTable({ + bookingId, + containers, + onSave, +}: ContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} · ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx new file mode 100644 index 000000000..85bba1dc4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface ContainerAllocationRow { + id: string; + type: string; + qty: number; +} + +export interface FirstMileContainerAllocationTableProps { + firstMileId: string; + containers: ContainerAllocationRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for first-mile pickups. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function FirstMileContainerAllocationTable({ + firstMileId, + containers, + onSave, +}: FirstMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} · ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx new file mode 100644 index 000000000..d11d99a4a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -0,0 +1,164 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Group, + Loader, + Select, + Stack, + Table, + Text, + Alert, +} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import toast from "react-hot-toast"; + +import { vehiclesService } from "@/services/vehicles.service"; + +export interface LastMileContainerRow { + id: string; + type: string; + qty: number; +} + +export interface LastMileContainerAllocationTableProps { + lastMileId: string; + containers: LastMileContainerRow[]; + onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; +} + +/** + * Manual container-to-vehicle allocation table for last-mile deliveries. + * Displays containers with type/qty, vehicle dropdown per row, and save action. + */ +export function LastMileContainerAllocationTable({ + lastMileId, + containers, + onSave, +}: LastMileContainerAllocationTableProps) { + const [allocations, setAllocations] = useState>( + () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + + const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ + queryKey: ["vehicles", "active"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + }); + + const vehicleOptions = useMemo( + () => + vehicles.map((v) => ({ + value: v.id, + label: `${v.plateNumber} (${v.vehicleType})`, + description: `${v.model} · ${v.manufacturer}`, + })), + [vehicles], + ); + + const saveAllocation = useMutation({ + mutationFn: async () => { + const mappings = containers + .filter((c) => allocations[c.id]) + .map((c) => ({ + containerId: c.id, + vehicleId: allocations[c.id]!, + })); + + if (mappings.length === 0) { + throw new Error("No containers allocated to vehicles"); + } + + await onSave(mappings); + }, + onSuccess: () => { + toast.success("Container allocations saved"); + setAllocations( + containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), + ); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to save allocations", + ); + }, + }); + + const allocatedCount = Object.values(allocations).filter(Boolean).length; + const allAllocated = allocatedCount === containers.length; + + if (vehiclesLoading) { + return ( + + + + ); + } + + return ( + + {vehicles.length === 0 && ( + } color="yellow"> + No active vehicles available. Add vehicles before allocating containers. + + )} + + + + + + Container ID + Type + Qty + Assigned Vehicle + + + + {containers.map((container) => ( + + + + {container.id} + + + {container.type} + {container.qty} + +
+
+ + + + {allocatedCount} of {containers.length} containers allocated + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx index b0b864e4d..c888253f1 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode }) ); } +const noteLineValue = (notes: string | null | undefined, label: string) => { + const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im')); + return match?.[1]?.trim() ?? ''; +}; + export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { const bookingReference = item?.booking?.reference ?? '-'; + const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference'); + const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At'); const inventorySummary = [ item?.status?.replace(/_/g, ' '), + item?.grnNumber ? `GRN ${item.grnNumber}` : null, item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null, + handoverReference ? `Handover ${handoverReference}` : null, item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null, ] .filter(Boolean) @@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM + + @@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM + 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 eb320d78b..924d4456e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react'; import { ActionIcon, Alert, @@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps { onReceived?: () => void; } +function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + + const openDocument = async (event: MouseEvent) => { + event.stopPropagation(); + if (!grnNumber) { + toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' }); + return; + } + setLoading(true); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadGrnDocument(inventoryId); + const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow); + toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} + interface Location { warehouseId: string; yardId: string; @@ -620,11 +659,13 @@ function EligibleTab({ const { toast } = useToast(); const qc = useQueryClient(); const { data: allRows = [], isLoading } = useQuery( - api.warehouses.eligibleBookings.queryOptions({ enabled }), + api.warehouses.eligibleBookings.queryOptions({ + input: { direction }, + enabled, + }), ); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); - const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); const requestFirstMile = useMutation({ mutationFn: (reference: string) => firstMileService.accept(reference), onSuccess: () => { @@ -782,10 +823,24 @@ function EligibleTab({ return; } const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); + const totalContainerQuantity = selectedRows.reduce( + (sum, row) => sum + Number(row.containerQuantity ?? 0), + 0, + ); + const normalizedForm = + nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0 + ? { + ...form, + unitCount: totalContainerQuantity, + } + : form; setPendingReceiveIds(filteredIds); setReceivedAt(new Date().toISOString()); - setTruckForm(form); - setLockedTruckFields(lockedFields); + setTruckForm(normalizedForm); + setLockedTruckFields({ + ...lockedFields, + unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0, + }); setPackagingFreightType(nextPackagingFreightType); setTruckOpen(true); }; @@ -798,18 +853,6 @@ function EligibleTab({ await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); }; - const loadPassedExport = async () => { - try { - const r = await loadPassed.mutateAsync(undefined); - toast({ - title: `${r.loadedCount} loaded`, - description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined, - }); - onChanged?.(); - } catch (error) { - toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) }); - } - }; return ( @@ -828,18 +871,6 @@ function EligibleTab({ Selected: {selected.size} / {statusFilteredRows.length} eligible - {direction === 'EXPORT' && ( - - )} @@ -1000,7 +1031,7 @@ function EligibleTab({ loading={bulkReceive.isPending} onClick={() => openTruckReceive([r.id])} > - {canReceive ? 'Receive to Warehouse' : 'Await First Mile'} + {canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'} )} @@ -1015,7 +1046,7 @@ function EligibleTab({ setTruckOpen(false)} - title="Receive to Warehouse" + title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'} centered size="lg" > @@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged /> Booking Ref + GRN Booking ID Customer ID Customer Name @@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged /> - {r.bookingReference ?? '—'} + + {r.bookingReference ?? '—'} + + + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} @@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: /> Booking Ref + GRN Booking ID Customer ID Customer Name @@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: /> - {r.bookingReference ?? '—'} + + {r.bookingReference ?? '—'} + + + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} @@ -1492,6 +1537,7 @@ function LoadedExportTab({ )} Booking Ref + GRN Booking ID Customer ID Customer Name @@ -1516,7 +1562,13 @@ function LoadedExportTab({ )} - {r.bookingReference ?? '—'} + + {r.bookingReference ?? '—'} + + + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} @@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId: row.bookingId, quantity: 1, weight: Number(row.weight) || 0, + grnNumber: row.grnNumber, status: row.currentStatus, arrivedAt: row.arrivalTime, unloadedAt: row.arrivalTime, inspectionStatus: row.inspectionStatus, releaseDate: row.releaseDate, releaseOrderReference: row.releaseOrderReference, + handoverDocumentReference: row.handoverDocumentReference, + handoverDocumentDate: row.handoverDocumentDate, deliveredAt: row.deliveredAt, booking: row.bookingId ? { @@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { try { const response = await warehouseService.downloadHandoverDocument(row.id); openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { pdfWindow?.close(); toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); @@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { } }; + const openReleaseDocument = async (row: ImportUnloadedItem) => { + setBusyId(row.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadReleaseDocument(row.id); + openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + return ( @@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Booking ID Booking Ref + GRN Customer ID Customer Name Arrival Time @@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - {r.bookingReference ?? '—'} + + {r.bookingReference ?? '—'} + + + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} @@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { color="yellow" onClick={() => setReleaseItem(toInventoryItem(r))} > - Truck Arrival + {r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'} )} @@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Dispatch )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + + )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( )} + + + )} + + {/* Tracked Vehicles List */} + + + Tracked Vehicles ({trackableVehicles.length}) +
+ + + {trackableVehicles.map(v => ( + setSelectedVehicleId(v.id)} + > + + + + {v.registrationNumber} + + + {v.gps.speed} km/h + + + + + + {v.status || 'N/A'} + + + + ))} + +
+
+
+
+ + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts new file mode 100644 index 000000000..6193ca58c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts @@ -0,0 +1,16 @@ +/** Shared formatting helpers for the fleet-management pages. */ + +/** Format a number as Ethiopian Birr, e.g. 12345.6 → "ETB 12,346". */ +export function formatETB(amount: number, fractionDigits = 0): string { + const value = Number.isFinite(amount) ? amount : 0; + return `ETB ${value.toLocaleString("en-US", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + })}`; +} + +/** Safe percentage of `part` over `total`, rounded, 0 when total is 0. */ +export function pct(part: number, total: number): number { + if (!total || !Number.isFinite(total) || !Number.isFinite(part)) return 0; + return Math.round((part / total) * 100); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index e17ea46c7..35618fa3b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -30,9 +30,11 @@ import { Text, TextInput, UnstyledButton, + Alert, } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; import { @@ -44,6 +46,7 @@ import { import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; +import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => @@ -336,6 +339,9 @@ const FirstMilePage = () => { const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); + const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), queryFn: async () => { @@ -434,6 +440,19 @@ const FirstMilePage = () => { }, }); + const allocateMutation = useMutation({ + mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), + onSuccess: () => { + toast({ title: "Containers allocated" }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); + setContainerAllocationOpen(false); + setContainerAllocationFirstMileId(null); + }, + onError: () => { + toast({ title: "Allocation failed", variant: "destructive" }); + }, + }); + const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -508,6 +527,16 @@ const FirstMilePage = () => { setInvoiceRecord(null); }; + const openContainerAllocation = (firstMileId: string) => { + setContainerAllocationFirstMileId(firstMileId); + setContainerAllocationOpen(true); + }; + + const closeContainerAllocation = () => { + setContainerAllocationOpen(false); + setContainerAllocationFirstMileId(null); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -530,7 +559,6 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { - if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -743,9 +771,25 @@ const FirstMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => { const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + const isPaid = (row.original as any).paid; if (!hasDistance) { return ; } + if (isPaid) { + return ( + + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + Paid + + ); + } return ( openInvoice(row.original)} @@ -1272,6 +1316,56 @@ const FirstMilePage = () => { + + {/* Container Allocation modal */} + Allocate Containers to Vehicles} + size="xl" + radius="lg" + centered + > + + {activeRecord && ( + <> + {/* Capacity guidance */} + {activeRecord.booking?.cargoType?.label === "BULK" ? ( + + + Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. + + + Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing + + + ) : ( + + + One vehicle per container. Each container will be assigned to a single vehicle. + + + )} + + + {/* Container table */} + { + await allocateMutation.mutateAsync(allocations); + }} + /> + + )} + + + + + ); }; 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 9798a90bf..70d9e9105 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -45,6 +45,8 @@ import { } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; +import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; +import { api } from "@/auth/http"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -321,6 +323,9 @@ const LastMilePage = () => { const [invoiceOpen, setInvoiceOpen] = useState(false); const [invoiceRecord, setInvoiceRecord] = useState(null); + const [allocationOpen, setAllocationOpen] = useState(false); + const [allocationContainers, setAllocationContainers] = useState([]); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.LAST_MILE.list(), queryFn: async () => { @@ -385,6 +390,19 @@ const LastMilePage = () => { }, }); + const allocateMutation = useMutation({ + mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => + api.post(`/last-mile/${activeId}/allocate-containers`, data), + onSuccess: () => { + toast({ title: "Containers allocated", variant: "default" }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); + closeAllocation(); + }, + onError: () => { + toast({ title: "Allocation failed", variant: "destructive" }); + }, + }); + const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), @@ -477,6 +495,18 @@ const LastMilePage = () => { setInvoiceRecord(null); }; + const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { + setActiveId(id); + setAllocationContainers(containers ?? []); + setAllocationOpen(true); + }; + + const closeAllocation = () => { + setAllocationOpen(false); + setActiveId(null); + setAllocationContainers([]); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -509,7 +539,6 @@ const LastMilePage = () => { ); const matchesFilter = (r: LastMileRecord) => { - if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -722,9 +751,25 @@ const LastMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => { const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + const isPaid = (row.original as any).paid; if (!hasDistance) { return ; } + if (isPaid) { + return ( + + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + Paid + + ); + } return ( openInvoice(row.original)} @@ -1243,6 +1288,76 @@ const LastMilePage = () => { + + {/* Container Allocation modal */} + Allocate Containers to Vehicles} + size="xl" + radius="lg" + centered + > + + {activeRecord && ( + <> + + + + + {bookingRef(activeRecord)} + {customerName(activeRecord)} + + + Cargo Type + {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"} + + + + + + {/* Capacity logic based on cargo type */} + {activeRecord.booking?.cargoType?.name === "BULK" ? ( + + + + Smart Capacity Allocation + + + Capacity: TBD + + TODO: add vehicle capacity_tons to vehicle API if missing + + + TODO: add container weight to booking if missing + + + + Select multiple containers per vehicle based on capacity + + + + ) : ( + + One vehicle per container + + )} + + )} + + { + await allocateMutation.mutateAsync(mappings); + }} + /> + + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx index bf086f1ce..f75d02304 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx @@ -1,5 +1,6 @@ -import { Button, Card } from '@mantine/core'; -import { PackageSearch } from 'lucide-react'; +import { useState } from 'react'; +import { Button, Card, Group, Modal, Stack } from '@mantine/core'; +import { PackageSearch, Truck } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { PageContainer, PageHeader } from '@/components/page'; @@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses'; export default function ExportWarehouseFlowPage() { const navigate = useNavigate(); + const [receiveOpen, setReceiveOpen] = useState(false); return ( @@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() { title="Export Operations" subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow." action={ - + + + + } /> + + setReceiveOpen(false)} + title="Receive for loading" + centered + size="80rem" + > + + + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 0f9496e3c..c32c162e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -15,7 +15,8 @@ import { Text, TextInput, } from '@mantine/core'; -import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react'; +import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; @@ -31,7 +32,7 @@ import { type WarehouseInvoiceStatus, } from '@/types/warehouse'; import { openPdfBlob } from '@/components/warehouses/pdf'; -import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf'; +import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf'; import { extractErrorMessage } from '@/components/warehouses/options'; const STATUS_COLOR: Record = { @@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const { toast } = useToast(); + const navigate = useNavigate(); const { data: inv, isLoading } = useQuery( api.warehouses.invoice.queryOptions({ input: { id: id ?? '' }, @@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE'); - openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceDocument(invoice.id); + openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT'); - openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id); + openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => { @@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => )} + {inv.bookingId && ( + + )} + )} - )} + {hasReceipt && ( + + )} + {payable && ( + + )} + - {payMutation.isError && ( - } title="Payment could not be started"> - Please try again, or contact support if the problem persists. - - )} - {/* Summary */} - + @@ -150,7 +275,12 @@ export default function InvoiceDetailPage() { - + Total @@ -241,6 +371,30 @@ export default function InvoiceDetailPage() { + + { + if (!payMutation.isPending) { + setPayModalOpen(false); + payMutation.reset(); + } + }} + amountLabel={formatCurrency( + Number(invoice.totalAmount), + invoice.currency, + )} + currency={invoice.currency} + processing={payMutation.isPending} + error={ + payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null + } + onConfirm={(method) => payMutation.mutate(method)} + /> ); diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index 1e19b1c9a..3503fbc71 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -22,6 +22,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" }, + [Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" }, }; export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index c1a5ee0c9..9dbb9ed10 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,5 @@ import { Box, Group, Text } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, Download, Eye } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -8,9 +8,12 @@ import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; +import { invoicesService } from "@/services/invoices.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; +import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; +import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { ContainersCard } from "./components/ContainersCard"; @@ -23,19 +26,22 @@ import { ConsolidationPairedNotice, ConsolidationWaitingBanner, } from "./components/Notices"; +import { BookingPaymentPanel } from "./components/BookingPaymentPanel"; import { HeaderButton, PageHeader } from "./components/PageHeader"; -import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard"; import { PaymentMethodModal } from "./components/PaymentMethodModal"; -import { PaymentCard } from "./components/pricing"; import { ScheduleCard } from "./components/ScheduleCard"; +import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; import { fmtDate, isNegative, priceTotal } from "./utils"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); + // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. + useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); const { view, viewer } = useFileViewer(); @@ -47,17 +53,41 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) : "/contracts/new"; const onRebook = () => navigate(rebookTo); - // POST /payments/initiate creates the intent and returns the provider's - // redirect URL (clientAction.url). Send the browser straight there; fall back - // to the public /payments/checkout page if no redirect URL came back. + // Billing is invoice-centric — resolve the booking's currently payable + // invoice (same query/key BookingPaymentPanel uses, so this shares its + // cache) and pay it through the ownership-checked portal route. + const { data: bookingInvoices = [] } = useQuery({ + queryKey: ["booking-invoices", booking.id], + queryFn: () => invoicesService.listForSource("booking", booking.id), + }); + const payableInvoiceId = bookingInvoices.find((inv) => + isPayable(inv.status), + )?.id; + + // POST /billing/my-invoices/:id/pay creates the intent and returns the + // provider's redirect URL (clientAction.url). Send the browser straight + // there; fall back to the public /payments/checkout page if no redirect + // URL came back. const payMutation = useMutation({ - mutationFn: (method: PaymentMethod) => - api.payments.initiate.call({ bookingId: booking.id, method }), + mutationFn: (method: PaymentMethod) => { + if (!payableInvoiceId) { + throw new Error( + "No payable invoice found for this booking yet. Please refresh or contact support.", + ); + } + return api.invoices.pay.call({ + id: payableInvoiceId, + payload: { method, platform: "web" }, + }); + }, onSuccess: (data, method) => { const redirectUrl = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url - : paymentsService.checkoutUrl({ bookingId: booking.id, method }); + : paymentsService.checkoutUrlForInvoice({ + invoiceId: payableInvoiceId!, + method, + }); window.location.href = redirectUrl; }, }); @@ -72,6 +102,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) (isGeneralContract ? status === "FULLY_EXECUTED" : status === "SELECTED_FOR_BATCH"); + const canApproveDelivery = status === "COMPLETED"; const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; @@ -93,14 +124,20 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } - label="Pay now" - onClick={() => setPayModalOpen(true)} - /> + (canApproveDelivery || (canPay && !showCountdown)) && ( + + {canApproveDelivery && ( + + )} + {canPay && !showCountdown && ( + } + label="Pay now" + onClick={() => setPayModalOpen(true)} + /> + )} + ) } menuActions={{ @@ -156,6 +193,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) + + {booking.files && booking.files.length > 0 && ( @@ -207,14 +246,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } right={ <> - {showCountdown && ( - setPayModalOpen(true)} - paying={payMutation.isPending} - /> - )} - + setPayModalOpen(true)} + paying={payMutation.isPending} + showCountdown={showCountdown} + /> ); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx new file mode 100644 index 000000000..d969f7a53 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -0,0 +1,370 @@ +import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + CheckCircle2, + CreditCard, + Download, + FileText, + Receipt, + Timer, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import toast from "react-hot-toast"; + +import type { Freight } from "@edr/types"; + +import { invoicesService, type PortalInvoice } from "@/services/invoices.service"; +import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; +import { saveBlob } from "@/utils/download"; + +import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +const Divider = () => ; + +// ── Pay-window countdown ───────────────────────────────────────────────────── + +interface Remaining { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +function getRemaining(deadlineMs: number): Remaining { + const diff = deadlineMs - Date.now(); + if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + const total = Math.floor(diff / 1000); + return { + days: Math.floor(total / 86400), + hours: Math.floor((total % 86400) / 3600), + minutes: Math.floor((total % 3600) / 60), + seconds: total % 60, + expired: false, + }; +} + +function Segment({ value, label }: { value: number; label: string }) { + return ( + + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +function Countdown({ + deadline, + onPay, + paying, +}: { + deadline: string; + onPay?: () => void; + paying?: boolean; +}) { + const deadlineMs = new Date(deadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + if (remaining.expired) { + return ( + + The payment window has closed. Move this booking to another schedule or + contact support. + + ); + } + + return ( + <> + + + + + + + + Deadline:{" "} + {new Date(deadline).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {onPay && ( + + )} + + ); +} + +// ── Merged payment panel ───────────────────────────────────────────────────── + +/** + * One card covering the whole payment story for a booking: the live pay-window + * countdown (when open), the price breakdown, and the invoice(s) — each with a + * link to its detail page and a download. Replaces the separate deadline + + * breakdown cards. + */ +export function BookingPaymentPanel({ + booking, + pricing, + onPay, + paying, + showCountdown, +}: { + booking: Freight.IBooking; + pricing: Pricing; + onPay?: () => void; + paying?: boolean; + showCountdown?: boolean; +}) { + const navigate = useNavigate(); + const paid = booking.paymentStatus === "PAID"; + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const currency = pricing?.currency ?? booking.paymentCurrency; + const total = isAdjusted + ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` + : priceTotal(pricing); + const items = priceLineItems(pricing); + + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-invoices", booking.id], + queryFn: () => invoicesService.listForSource("booking", booking.id), + }); + // The invoice worth a prominent "Download" — the first issued one, else any. + const primary = + invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0]; + const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false; + + const downloadInvoice = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadDocument(inv.id), + `invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists."); + } + }; + + const downloadReceipt = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadReceipt(inv.id), + `receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Payment + + {paid ? : showCountdown ? : null} + {paid + ? "Paid" + : showCountdown + ? "Pay window open" + : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + + + + {showCountdown && booking.paymentDeadline && ( + + + + + )} + + + + {total} + + {isAdjusted && ( + + Adjusted by EDR + + )} + {isAdjusted && booking.adjustmentReason && ( + + {booking.adjustmentReason} + + )} + {paid && ( + + Paid · {fmtDate(booking.updatedAt)} + + )} + + + {items.length > 0 && ( + <> + + + {items.map((it) => ( + + + {it.label} + + + {it.value} + + + ))} + + + + {isAdjusted ? "Adjusted total" : "Total"} + + + {total} + + + + )} + + {invoices.length > 0 && ( + <> + + + Invoices + + {invoices.length} + + + + {invoices.map((inv) => ( + + + navigate(`/billing/${inv.id}`)} + > + {inv.invoiceNumber} + + + {titleCase(inv.type)} + + + + + downloadInvoice(inv)} + > + + + + + ))} + + + )} + + {primary && ( + + )} + {primary && primaryPaid && ( + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx deleted file mode 100644 index 54f900ab5..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Box, Button, Group, Stack, Text } from "@mantine/core"; -import { CreditCard, Timer } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { CardTitle, SectionCard } from "./layout"; - -interface Remaining { - days: number; - hours: number; - minutes: number; - seconds: number; - expired: boolean; -} - -function getRemaining(deadlineMs: number): Remaining { - const diff = deadlineMs - Date.now(); - if (diff <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; - } - const totalSeconds = Math.floor(diff / 1000); - return { - days: Math.floor(totalSeconds / 86400), - hours: Math.floor((totalSeconds % 86400) / 3600), - minutes: Math.floor((totalSeconds % 3600) / 60), - seconds: totalSeconds % 60, - expired: false, - }; -} - -function Segment({ value, label }: { value: number; label: string }) { - return ( - - - {String(value).padStart(2, "0")} - - - {label} - - - ); -} - -export function PaymentDeadlineCard({ - paymentDeadline, - onPay, - paying, -}: { - /** ISO timestamp marking the end of the pay window. */ - paymentDeadline: string; - onPay?: () => void; - paying?: boolean; -}) { - const deadlineMs = new Date(paymentDeadline).getTime(); - const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); - - useEffect(() => { - setRemaining(getRemaining(deadlineMs)); - const interval = setInterval(() => { - const next = getRemaining(deadlineMs); - setRemaining(next); - if (next.expired) clearInterval(interval); - }, 1000); - return () => clearInterval(interval); - }, [deadlineMs]); - - const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6"; - const accentFg = remaining.expired ? "#C0392B" : "#B07D14"; - - return ( - - - Payment deadline - - - {remaining.expired ? "Expired" : "Pay window open"} - - - - {remaining.expired ? ( - - The payment window has closed. Move this booking to another schedule or - contact support. - - ) : ( - <> - - - - - - - - Complete payment before the window closes to secure your slot. - - {onPay && ( - - )} - - )} - - - - Deadline:{" "} - {new Date(paymentDeadline).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx new file mode 100644 index 000000000..146e31c1e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -0,0 +1,156 @@ +import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download, Receipt } from "lucide-react"; +import toast from "react-hot-toast"; + +import { + warehouseInvoicesService, + type PortalWarehouseInvoice, +} from "@/services/warehouse-invoices.service"; +import { saveBlob } from "@/utils/download"; + +import { CardTitle, SectionCard } from "./layout"; + +const money = (amount: number | string | null | undefined, currency: string) => + `${Number(amount ?? 0).toLocaleString()} ${currency}`; + +const STATUS_STYLE: Record = { + DRAFT: { bg: "#EEF2F6", fg: "#64748B" }, + ISSUED: { bg: "#FEF3E2", fg: "#B45309" }, + PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" }, + PAID: { bg: "#E6F7EF", fg: "#0A6F4D" }, + CANCELLED: { bg: "#EEF2F6", fg: "#64748B" }, +}; + +function StatusPill({ status }: { status: string }) { + const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" }; + return ( + + {status.replace(/_/g, " ")} + + ); +} + +/** + * Warehouse fee invoices linked to this booking — display + PDF download only. + * Paying them online is tracked separately (in-system demurrage/storage + * payment). Renders nothing when the booking has no warehouse fees. Carries + * `id="warehouse-payments"` so the invoice detail page can deep-link here. + */ +export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-warehouse-invoices", bookingId], + queryFn: () => warehouseInvoicesService.listForBooking(bookingId), + }); + + if (invoices.length === 0) return null; + + const download = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadDocument(inv.id), + `warehouse-invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Warehouse invoice PDF isn't ready yet."); + } + }; + + const downloadReceipt = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadReceipt(inv.id), + `warehouse-receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Warehouse payments + + {invoices.length} {invoices.length === 1 ? "invoice" : "invoices"} + + + + {invoices.map((inv) => { + const detail = [ + inv.invoiceType?.replace(/_/g, " "), + inv.cargoDescription ?? + inv.containerNumber ?? + inv.inventoryReference ?? + undefined, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + + {inv.invoiceNumber} + + + + {detail && ( + + {detail} + + )} + + Total {money(inv.totalAmount, inv.currency)} · Balance{" "} + {money(inv.balanceAmount, inv.currency)} + + + + download(inv)} + > + + + {Number(inv.paidAmount) > 0 && ( + downloadReceipt(inv)} + > + + + )} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index 93d843065..8c4ab9bcb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -1,9 +1,7 @@ import { Box, Group, Stack, Text } from "@mantine/core"; -import { CheckCircle2, Clock } from "lucide-react"; +import { Clock } from "lucide-react"; -import type { Freight } from "@edr/types"; - -import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { priceLineItems, priceTotal, type Pricing } from "../utils"; import { CardTitle, SectionCard } from "./layout"; function LineItems({ pricing }: { pricing: Pricing }) { @@ -107,113 +105,6 @@ export function EstimateCard({ ); } -export function PaymentCard({ - booking, - pricing, -}: { - booking: Freight.IBooking; - pricing: Pricing; -}) { - const paid = booking.paymentStatus === "PAID"; - // Customer sees the grand total plus the price breakdown that makes it up. - // A staff adjustment, when present, overrides the computed total and is - // flagged with an "Adjusted by EDR" badge. - const isAdjusted = - booking.adjustedTotalAmount !== null && - booking.adjustedTotalAmount !== undefined; - const currency = pricing?.currency ?? booking.paymentCurrency; - const total = isAdjusted - ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` - : priceTotal(pricing); - const hasItems = priceLineItems(pricing).length > 0; - - return ( - - - Payment - - {paid && } - {paid - ? "Paid" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} - - - - - {total} - - {isAdjusted && ( - - Adjusted by EDR - - )} - {isAdjusted && booking.adjustmentReason && ( - - {booking.adjustmentReason} - - )} - {paid && ( - - Paid · {fmtDate(booking.updatedAt)} - - )} - - {hasItems && ( - <> - - - - - {isAdjusted ? "Adjusted total" : "Total"} - - - {total} - - - - )} - {/* */} - - ); -} +// The booking payment card (countdown + breakdown + invoices + download) now +// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and +// changes-requested views, which only show an estimate. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx new file mode 100644 index 000000000..0f0ded4ef --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx @@ -0,0 +1,73 @@ +import { Button, type ButtonProps } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2 } from "lucide-react"; +import type { MouseEvent } from "react"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; + +type ApproveDeliveryButtonProps = ButtonProps & { + bookingId: string; + stopPropagation?: boolean; + onApproved?: () => void; +}; + +const errorMessage = (error: unknown) => { + const data = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : "Could not approve delivery"; +}; + +export function ApproveDeliveryButton({ + bookingId, + stopPropagation, + onApproved, + size = "sm", + variant = "filled", + ...props +}: ApproveDeliveryButtonProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + ...api.bookings.approveDelivery.mutationOptions(), + onSuccess: async () => { + toast.success("Delivery approved and handover signed"); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }), + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), + ]); + onApproved?.(); + }, + onError: (error) => { + const message = errorMessage(error); + toast.error(message); + if (message.toLowerCase().includes("save your signature")) { + navigate("/signature"); + } + }, + }); + + const handleClick = (event: MouseEvent) => { + if (stopPropagation) event.stopPropagation(); + mutation.mutate({ id: bookingId }); + }; + + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 1526e69b6..7c9c44b99 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -8,6 +8,7 @@ import type { } from "@/types/fileUploadSettings"; import { bookingsService, + type ApproveDeliveryResponse, BookingListFilter, CreateBookingPayload, GeneratePriceResponse, @@ -303,6 +304,12 @@ export const api = { ({ orderId }) => bookingsService.checkPayment(orderId), ), + approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>( + "bookings", + "approveDelivery", + ({ id }) => bookingsService.approveDelivery(id), + ), + getBookableSchedules: endpoint< { originYardId?: string; destinationYardId?: string }, Freight.BookableScheduleItem[] diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts index 99b1d0dee..2ee18ec4e 100644 --- a/apps/edr-freight-web/portal/src/services/invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts @@ -29,12 +29,39 @@ export const invoicesService = { return data.data ?? data; }, + /** The customer's invoices for one source record (e.g. a booking). */ + listForSource: async ( + source: string, + sourceId: string, + ): Promise => { + const { data } = await client.get(B.MY_INVOICES, { + params: { source, sourceId }, + }); + return data.data ?? data; + }, + /** One of the customer's invoices, with its line items. */ get: async (id: string): Promise => { const { data } = await client.get(B.MY_INVOICE_BY_ID(id)); return data.data ?? data; }, + /** The sealed invoice PDF for one of the customer's invoices. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), { + responseType: "blob", + }); + return data; + }, + + /** The sealed payment-receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), { + responseType: "blob", + }); + return data; + }, + /** Initiate gateway payment for an open invoice; returns the client action. */ pay: async ( id: string, diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts index 119aea4ed..c62e31108 100644 --- a/apps/edr-freight-web/portal/src/services/payments.service.ts +++ b/apps/edr-freight-web/portal/src/services/payments.service.ts @@ -17,7 +17,7 @@ export type PaymentMethod = export type PaymentPlatform = "web" | "mobile"; export interface InitiatePaymentPayload { - bookingId: string; + invoiceId: string; method: PaymentMethod; platform?: PaymentPlatform; payerAccount?: string; @@ -67,6 +67,25 @@ function buildCheckoutUrl(payload: { return `${base}${P.CHECKOUT}?${params.toString()}`; } +/** + * Checkout fallback keyed by invoice id — matches `GET /payments/checkout`, + * which reads `invoiceId` (billing is invoice-centric; there is no + * `bookingId` param on that route). + */ +function buildCheckoutUrlForInvoice(payload: { + invoiceId: string; + method: PaymentMethod; + platform?: PaymentPlatform; +}): string { + const base = API_BASE_URL.replace(/\/$/, ""); + const params = new URLSearchParams({ + invoiceId: payload.invoiceId, + method: payload.method, + platform: payload.platform ?? "web", + }); + return `${base}${P.CHECKOUT}?${params.toString()}`; +} + export const paymentsService = { initiate: async ( payload: InitiatePaymentPayload, @@ -84,4 +103,5 @@ export const paymentsService = { }, checkoutUrl: buildCheckoutUrl, + checkoutUrlForInvoice: buildCheckoutUrlForInvoice, }; diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts new file mode 100644 index 000000000..f1fa4e841 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -0,0 +1,54 @@ +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const W = URL_CONSTANTS.WAREHOUSE_INVOICES; + +/** + * A warehouse fee invoice as the freight API projects it for the customer + * (the historical `WarehouseFeeInvoice` view shape — a subset is used here). + */ +export interface PortalWarehouseInvoice { + id: string; + invoiceNumber: string; + invoiceType: string; + status: string; + currency: string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + issuedAt?: string | null; + dueDate?: string | null; + paidAt?: string | null; + bookingId?: string | null; + inventoryId?: string | null; + bookingReference?: string | null; + inventoryReference?: string | null; + cargoDescription?: string | null; + containerNumber?: string | null; +} + +export const warehouseInvoicesService = { + /** Warehouse fee invoices linked to a booking (via its inventory items). */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(W.FOR_BOOKING(bookingId)); + return data.data ?? data; + }, + + /** A single warehouse fee invoice (carries `bookingId` for source linking). */ + get: async (id: string): Promise => { + const { data } = await client.get(W.BY_ID(id)); + return data.data ?? data; + }, + + /** The sealed warehouse fee invoice PDF. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" }); + return data; + }, + + /** The sealed warehouse fee payment receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); + return data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/utils/download.ts b/apps/edr-freight-web/portal/src/utils/download.ts new file mode 100644 index 000000000..594513566 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/download.ts @@ -0,0 +1,11 @@ +/** Trigger a browser download of a Blob under `filename`. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index a4774af84..aaed09659 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -3,6 +3,7 @@ NODE_ENV=development PORT=4000 # Database (Prisma) — owns the `passenger` schema in edr_database +# Production: append ?sslmode=require&connection_limit=10&pool_timeout=20 to enforce SSL and connection pooling DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger # Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database. @@ -32,16 +33,23 @@ FRONTEND_URL=http://localhost:5174 BACK_OFFICE_URL=http://localhost:5184 # JWT (legacy passenger auth — being replaced by IAM) -JWT_SECRET=edr-platform-secret-change-in-production +# REQUIRED in production — use a random 32+ character string (e.g. openssl rand -hex 32) +JWT_SECRET= JWT_EXPIRES_IN=7d -# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with -# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.) -JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me +# @tria-plc IAM token contract — REQUIRED in production. MUST match the IAM issuer's secret. +JWT_ACCESS_TOKEN_SECRET= JWT_ACCESS_TOKEN_EXPIRES=1h -JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me +JWT_REFRESH_TOKEN_SECRET= JWT_REFRESH_TOKEN_EXPIRES=7d +# @tria-plc IAM forgot-password flow — the reset link sent via SMS is +# ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. +# Point at the backoffice web app. Without it the link starts with "undefined/". +FE_BASE_URL=http://localhost:5184 +# OTP/reset-link TTL in minutes (IAM default: 30) +INVITATION_EXPIRY_DATE=30 + # SendGrid SENDGRID_API_KEY= SENDGRID_FROM_EMAIL=noreply@edr-platform.com @@ -150,14 +158,26 @@ FAYDA_TOKEN_ENDPOINT= FAYDA_USERINFO_ENDPOINT= # Base64 of the RSA private JWK (JSON). Secret — never commit a real value. FAYDA_PRIVATE_KEY_BASE64= +# OAuth redirect_uri passed to eSignet for MOBILE clients (the app calls /complete directly). FAYDA_REDIRECT_URI= +# OAuth redirect_uri passed to eSignet for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. +FAYDA_WEB_REDIRECT_URI= # Optional (defaults shown) FAYDA_SCOPE=openid profile email FAYDA_ACR_VALUES=mosip:idp:acr:generated-code FAYDA_CLAIMS_LOCALES=en am FAYDA_SESSION_TTL_MINUTES=10 -GITHUB_PACKAGE_TOKEN= +GITHUB_PACKAGE_TOKEN= + +# --- Seeding ----------------------------------------------------------------- +# Set both to true on first run (or when resetting) to seed org, roles, and +# default backoffice staff users. Safe to leave true — all operations are idempotent. +# Login endpoint for backoffice users: POST /v1/auth/login +SEED_EDR_PASSENGER_ORG=false +SEED_PASSENGER_STAFF=false +# Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset. +DEFAULT_PASSWORD=Admin@1234 # --- Notification broker (RabbitMQ) ----------------------------------------------------------------- # Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker). diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 2b0ee8041..45b365496 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -4,6 +4,12 @@ # `migration` stage, invoked as a one-shot container in CI before deploy. FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat +# Put the pnpm content-addressable store under PNPM_HOME so the BuildKit +# `--mount=type=cache,target=/pnpm/store` below actually persists it across +# builds. Without this, pnpm stores in ~/.local/share/pnpm/store and the +# cache mount is a no-op — deps re-download on every pipeline run. +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app FROM base AS pruner @@ -22,11 +28,14 @@ RUN pnpm --filter "@edr/passenger-api" exec prisma generate RUN pnpm turbo build --filter="@edr/passenger-api..." FROM base AS deployer COPY --from=builder /app/ . -RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy -RUN if [ -d node_modules/.prisma ]; then \ - mkdir -p /deploy/node_modules && \ - cp -r node_modules/.prisma /deploy/node_modules/.prisma; \ - fi +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm deploy --filter="@edr/passenger-api" --legacy /deploy +# The generated Prisma client is NOT in the pnpm store (it's an output of +# `prisma generate`), so `pnpm deploy` does not copy it into /deploy. Regenerate +# it here so the runtime enum values imported from @prisma/client (Currency, …) +# are real objects instead of undefined — otherwise @IsEnum(Currency) throws +# "Cannot convert undefined or null to object" at module load. +RUN cd /deploy && npm run prisma:generate # --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...` # against the real DB, as its own gated step *before* the app image is built/deployed. diff --git a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql deleted file mode 100644 index a3a9b7445..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240101000000_individual_tickets_no_timezone/migration.sql +++ /dev/null @@ -1,46 +0,0 @@ --- DropForeignKey -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey"; - --- DropIndex -DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key"; - --- AlterTable: Station -ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone"; - --- AlterTable: Ticket — add columns with safe defaults -ALTER TABLE "passenger"."Ticket" - ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1, - ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS "scheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT ''; - --- DropTable -DROP TABLE IF EXISTS "passenger"."TicketSeat"; - --- Remove GateValidationLog rows referencing orphan tickets first -DELETE FROM "passenger"."GateValidationLog" -WHERE "ticketId" IN ( - SELECT "id" FROM "passenger"."Ticket" - WHERE "seatId" = '' - OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat") -); - --- Remove orphan ticket rows -DELETE FROM "passenger"."Ticket" -WHERE "seatId" = '' - OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId"); - --- AddForeignKey -ALTER TABLE "passenger"."Ticket" - ADD CONSTRAINT "Ticket_seatId_fkey" - FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") - ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql b/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql deleted file mode 100644 index 0e9961c0a..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20240102000000_drop_ticket_column_defaults/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Drop temporary defaults that were only needed for the backfill -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT; -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT; diff --git a/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql b/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql deleted file mode 100644 index 028b08500..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20241201000000_remove_station_timezone/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Remove timezone column if it still exists -ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone"; \ No newline at end of file diff --git a/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql b/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql deleted file mode 100644 index e9ba7761b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20250106070000_add_gender_to_traveler_profile/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql deleted file mode 100644 index 4b1fbd19c..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ /dev/null @@ -1,127 +0,0 @@ --- Migration: Add Configurable Fare Management System - --- Main fare configuration table -CREATE TABLE IF NOT EXISTS "fare_configurations" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "effective_date" TIMESTAMP(3) NOT NULL, - "expiry_date" TIMESTAMP(3), - "is_active" BOOLEAN NOT NULL DEFAULT false, - "is_default" BOOLEAN NOT NULL DEFAULT false, - "created_by" TEXT, - "approved_by" TEXT, - "approved_at" TIMESTAMP(3), - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id") -); - --- Rate structure by nationality and coach/position -CREATE TABLE IF NOT EXISTS "fare_rate_rules" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL' - "coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED' - "bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats - "rate_per_km_minor" INTEGER NOT NULL, - "is_active" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id") -); - --- Configurable fare components (insurance, premiums, service charges, taxes) -CREATE TABLE IF NOT EXISTS "fare_components" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND' - "component_name" TEXT NOT NULL, - "calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT' - "value_minor" INTEGER, -- For fixed amounts - "percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%) - "applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL' - "apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application - "is_active" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id") -); - --- Age-based pricing rules -CREATE TABLE IF NOT EXISTS "age_pricing_rules" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "rule_name" TEXT NOT NULL, - "min_age" INTEGER NOT NULL, - "max_age" INTEGER, - "pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED' - "discount_percentage" DECIMAL(5,4), -- For discounted fares - "max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child) - "applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too - "is_active" BOOLEAN NOT NULL DEFAULT true, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id") -); - --- Audit trail for configuration changes -CREATE TABLE IF NOT EXISTS "fare_configuration_audit" ( - "id" TEXT NOT NULL, - "fare_config_id" TEXT NOT NULL, - "action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED' - "changed_by" TEXT, - "changes" JSONB, -- Store the actual changes made - "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id") -); - --- Foreign key constraints (idempotent) -DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN - ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN - ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN - ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN - ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; -END $$; - --- Indexes for performance (idempotent) -CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); -CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); -CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; - -CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); -CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); -CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); - --- Add legacy mode flag to existing fare tables for gradual migration (idempotent) -ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; -ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; - --- Add feature flag support -CREATE TABLE IF NOT EXISTS "system_features" ( - "id" TEXT NOT NULL, - "feature_name" TEXT NOT NULL UNIQUE, - "is_enabled" BOOLEAN NOT NULL DEFAULT false, - "config" JSONB, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") -); - --- Insert the configurable fares feature flag -INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") -VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP); diff --git a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql deleted file mode 100644 index 9ccd3d52e..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260606000000_add_iam_user_id_to_passenger/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- AddColumn: iamUserId to Passenger (cross-schema reference to iam.users — no FK enforced) -ALTER TABLE "passenger"."Passenger" ADD COLUMN "iamUserId" TEXT; - --- Unique constraint: one IAM user maps to exactly one Passenger -ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); - --- Index for fast lookup by iamUserId on every protected request -CREATE INDEX "Passenger_iamUserId_idx" ON "passenger"."Passenger"("iamUserId"); - --- AddColumn: iamUserId to FaydaVerificationSession (no FK — cross-schema reference to iam.users) -ALTER TABLE "passenger"."FaydaVerificationSession" ADD COLUMN "iamUserId" TEXT; - --- Index for Fayda callback to resolve IAM user -CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "passenger"."FaydaVerificationSession"("iamUserId"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql deleted file mode 100644 index 525b6572e..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260607140721_add_segment_fare_rule/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- CreateTable -CREATE TABLE "SegmentFareRule" ( - "id" TEXT NOT NULL, - "routeId" TEXT NOT NULL, - "originStopSequence" INTEGER NOT NULL, - "destinationStopSequence" INTEGER NOT NULL, - "seatClassId" TEXT NOT NULL, - "baseFareMinor" INTEGER NOT NULL, - "nationality" TEXT, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "validFrom" TIMESTAMP(3) NOT NULL, - "validUntil" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId"); - --- CreateIndex -CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality"); - --- AddForeignKey -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql deleted file mode 100644 index f2250e52b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608061918_make_passenger_userid_nullable/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ --- DropForeignKey -ALTER TABLE "passenger"."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; - --- AlterTable -ALTER TABLE "passenger"."Passenger" ALTER COLUMN "userId" DROP NOT NULL; - --- CreateTable -CREATE TABLE IF NOT EXISTS "passenger"."TicketSeat" ( - "id" TEXT NOT NULL, - "ticketId" TEXT NOT NULL, - "seatId" TEXT NOT NULL, - "seatIndex" INTEGER NOT NULL DEFAULT 0, - - CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "TicketSeat_ticketId_idx" ON "passenger"."TicketSeat"("ticketId"); - --- CreateIndex -CREATE INDEX IF NOT EXISTS "TicketSeat_seatId_idx" ON "passenger"."TicketSeat"("seatId"); - --- AddForeignKey -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'Passenger_userId_fkey' - AND conrelid = 'passenger."Passenger"'::regclass - ) THEN - ALTER TABLE "passenger"."Passenger" ADD CONSTRAINT "Passenger_userId_fkey" - FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; - END IF; -END $$; - --- AddForeignKey -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_ticketId_fkey' - AND conrelid = 'passenger."TicketSeat"'::regclass - ) THEN - ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" - FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; - END IF; -END $$; - --- AddForeignKey -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'TicketSeat_seatId_fkey' - AND conrelid = 'passenger."TicketSeat"'::regclass - ) THEN - ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" - FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - END IF; -END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql deleted file mode 100644 index ec1cfd078..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608080000_rename_userid_to_iamuserid_on_preferences_device_fraudalert/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Drop FK constraints (they reference iam.users indirectly via local User, but these are within passenger schema) -ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; -ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; -ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; - --- Rename columns (preserves all existing data) -ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; -ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; -ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; - --- Rename indexes on FraudAlert to match new column name -DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; -CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql deleted file mode 100644 index 52914b220..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260608090000_rename_auditlog_userid_drop_fayda_userid/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- AuditLog: drop FK, rename column, update index -ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; -ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; -DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; -CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); - --- FaydaVerificationSession: drop userId column and FK (iamUserId already carries this data) -ALTER TABLE passenger."FaydaVerificationSession" DROP CONSTRAINT IF EXISTS "FaydaVerificationSession_userId_fkey"; -ALTER TABLE passenger."FaydaVerificationSession" DROP COLUMN IF EXISTS "userId"; -DROP INDEX IF EXISTS passenger."FaydaVerificationSession_userId_idx"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql deleted file mode 100644 index 125074c12..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260609065750_add_blocked_until_to_passenger/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "Passenger" ADD COLUMN "blockedUntil" TIMESTAMP(3); - --- RenameIndex -ALTER INDEX "UserPreferences_userId_key" RENAME TO "UserPreferences_iamUserId_key"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql deleted file mode 100644 index 9b9228768..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterEnum -ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY'; diff --git a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql deleted file mode 100644 index 577312395..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260617042447_add_return_schedule_id/migration.sql +++ /dev/null @@ -1,275 +0,0 @@ --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey"; - --- DropForeignKey -ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey"; - --- DropForeignKey -ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey"; - --- DropForeignKey -ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey"; - --- DropForeignKey -ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey"; - --- DropForeignKey -ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; - --- AlterTable -ALTER TABLE "Booking" ADD COLUMN "returnDestinationStationId" TEXT, -ADD COLUMN "returnHoldId" TEXT, -ADD COLUMN "returnOriginStationId" TEXT, -ADD COLUMN "returnScheduleId" TEXT, -ADD COLUMN "returnSeatClassId" TEXT; - --- AlterTable -ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; - --- AlterTable -ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; - --- gender column already TEXT from init migration - --- CreateIndex -CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql deleted file mode 100644 index 7e7d9bd58..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260618120000_ensure_booking_seat_leg_column/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Empty placeholder migration -SELECT 1; diff --git a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql deleted file mode 100644 index 1673a795b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence"); - -CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence"); - --- Ensure all indexes exist -CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); -CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); -CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId"); -CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql deleted file mode 100644 index 9f70a96b1..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql +++ /dev/null @@ -1,164 +0,0 @@ --- Add CASCADE delete to all foreign key constraints that are missing it - --- TrainSchedule relations -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE; - -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE; - -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE; - -ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE; - --- Coach relation -ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE; - --- CoachAssignment relations -ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - -ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE; - --- Booking relations -ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; - -ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- BookingSeat relations -ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - -ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; - --- PaymentIntent -ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; -ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- PaymentRefund -ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; -ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE; - --- Ticket -ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; -ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- TicketSeat -ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; - --- WalletLedgerEntry -ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; -ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE; - --- Notification -ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; - --- MenuItem -ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - -ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE; - --- FoodOrder -ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; -ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- FoodOrderItem -ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; -ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE; - --- FaqArticle -ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; -ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE; - --- SupportMessage -ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; -ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE; - --- TripStopTime -ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; -ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- TripLiveStatus -ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; -ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- JourneySegment -ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE; - -ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE; - --- AgentBooking -ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; - -ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- AgentShift -ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; -ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; - --- AgentCommission -ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; -ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE; - --- BookingModification -ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; -ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- BookingCancellation -ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; -ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- GateValidationLog -ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; -ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE; - --- BaggageBooking -ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; -ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE; - --- RouteFareRule -ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; -ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; - --- SegmentFareRule -ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; - --- StationCrowdSignal -ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; -ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE; - --- SeatBlock -ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; -ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE; - --- SavedRoute -ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; -ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE; - --- LoyaltyLedgerEntry -ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; -ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; - --- LoyaltyReward -ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; -ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE; - --- FareRule -ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; -ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql deleted file mode 100644 index 582db9567..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000000_catchup_iam_columns/migration.sql +++ /dev/null @@ -1,82 +0,0 @@ --- Catch-up migration: earlier migrations (20260606, 20260608) targeted passenger.* --- but ran when tables were still in public schema (before 20260626 moved them). --- All statements use IF NOT EXISTS / conditional blocks so this is safe to re-run. - --- ──────────────────────────────────────────────────────────── --- 1. Passenger.iamUserId --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Passenger" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; - -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'Passenger_iamUserId_key' - AND conrelid = 'passenger."Passenger"'::regclass - ) THEN - ALTER TABLE passenger."Passenger" ADD CONSTRAINT "Passenger_iamUserId_key" UNIQUE ("iamUserId"); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS "Passenger_iamUserId_idx" ON passenger."Passenger"("iamUserId"); - --- ──────────────────────────────────────────────────────────── --- 2. FaydaVerificationSession.iamUserId --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."FaydaVerificationSession" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; -CREATE INDEX IF NOT EXISTS "FaydaVerificationSession_iamUserId_idx" ON passenger."FaydaVerificationSession"("iamUserId"); - --- ──────────────────────────────────────────────────────────── --- 3. UserPreferences: rename userId → iamUserId (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'UserPreferences' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."UserPreferences" DROP CONSTRAINT IF EXISTS "UserPreferences_userId_fkey"; - ALTER TABLE passenger."UserPreferences" RENAME COLUMN "userId" TO "iamUserId"; - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 4. Device: rename userId → iamUserId (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'Device' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."Device" DROP CONSTRAINT IF EXISTS "Device_userId_fkey"; - ALTER TABLE passenger."Device" RENAME COLUMN "userId" TO "iamUserId"; - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 5. FraudAlert: rename userId → iamUserId + fix index (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'FraudAlert' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."FraudAlert" DROP CONSTRAINT IF EXISTS "FraudAlert_userId_fkey"; - ALTER TABLE passenger."FraudAlert" RENAME COLUMN "userId" TO "iamUserId"; - DROP INDEX IF EXISTS passenger."FraudAlert_userId_createdAt_idx"; - CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON passenger."FraudAlert"("iamUserId", "createdAt"); - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 6. AuditLog: rename userId → iamUserId + fix index (if not yet renamed) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'AuditLog' AND column_name = 'userId' - ) THEN - ALTER TABLE passenger."AuditLog" DROP CONSTRAINT IF EXISTS "AuditLog_userId_fkey"; - ALTER TABLE passenger."AuditLog" RENAME COLUMN "userId" TO "iamUserId"; - DROP INDEX IF EXISTS passenger."AuditLog_userId_createdAt_idx"; - CREATE INDEX IF NOT EXISTS "AuditLog_iamUserId_createdAt_idx" ON passenger."AuditLog"("iamUserId", "createdAt"); - END IF; -END $$; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql deleted file mode 100644 index 33f793aa3..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000001_make_passenger_userid_nullable/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 20260608061918 was marked-as-applied without running (it failed on CREATE TABLE TicketSeat). --- The two ALTER TABLE statements it contained never executed, so userId is still NOT NULL. - -ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; -ALTER TABLE passenger."Passenger" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql deleted file mode 100644 index fb2e47592..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260622000002_agent_iam_user_id_drop_user_fks/migration.sql +++ /dev/null @@ -1,46 +0,0 @@ --- ──────────────────────────────────────────────────────────── --- 1. Add iamUserId to Agent --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; - -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'Agent_iamUserId_key' - AND conrelid = 'passenger."Agent"'::regclass - ) THEN - ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); - --- ──────────────────────────────────────────────────────────── --- 2. Populate iamUserId for existing agent records --- Match via User.email → iam.users.email (skip if iam schema absent) --- ──────────────────────────────────────────────────────────── -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = 'iam' AND table_name = 'users' - ) THEN - UPDATE passenger."Agent" a - SET "iamUserId" = iu.id - FROM passenger."User" u - JOIN iam.users iu ON iu.email = u.email - WHERE a."userId" = u.id - AND a."iamUserId" IS NULL; - END IF; -END $$; - --- ──────────────────────────────────────────────────────────── --- 3. Drop Agent.userId FK and column — iamUserId replaces it entirely --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; -DROP INDEX IF EXISTS passenger."Agent_userId_key"; -ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; - --- ──────────────────────────────────────────────────────────── --- 4. Drop Passenger.userId FK (column stays as plain nullable string) --- ──────────────────────────────────────────────────────────── -ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql deleted file mode 100644 index a20893705..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql +++ /dev/null @@ -1,290 +0,0 @@ --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey"; - --- DropForeignKey -ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey"; - --- DropForeignKey -ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey"; - --- DropForeignKey -ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey"; - --- DropForeignKey -ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey"; - --- DropForeignKey -ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey"; - --- DropForeignKey -ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey"; - --- DropForeignKey -ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey"; - --- DropForeignKey -ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey"; - --- DropForeignKey -ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey"; - --- DropForeignKey -ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey"; - --- DropForeignKey -ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey"; - --- DropForeignKey -ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey"; - --- DropForeignKey -ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; - --- DropForeignKey -ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; - --- DropIndex -DROP INDEX IF EXISTS "Journey_bookingId_idx"; - --- AlterTable -ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY'; - --- CreateTable -CREATE TABLE "SystemConfig" ( - "id" TEXT NOT NULL, - "key" TEXT NOT NULL, - "value" TEXT NOT NULL, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'passenger' AND table_name = 'Journey' AND column_name = 'bookingId' - ) THEN - ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; - END IF; -END $$; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql deleted file mode 100644 index bc6e6c2a2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateEnum -CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); - --- AlterTable: add return leg tracking columns to Booking -ALTER TABLE "Booking" - ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', - ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), - ADD COLUMN "returnBoardedAt" TIMESTAMP(3); - --- Set NEITHER_USED for existing confirmed round-trip bookings -UPDATE "Booking" -SET "returnLegStatus" = 'NEITHER_USED' -WHERE "bookingType" = 'ROUND_TRIP' - AND "status" IN ('CONFIRMED', 'BOARDED'); - --- AlterTable: add leg column to GateValidationLog -ALTER TABLE "GateValidationLog" - ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql deleted file mode 100644 index b0a5bc0b4..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Create passenger schema if it doesn't exist -CREATE SCHEMA IF NOT EXISTS passenger; - --- Move enums from public to passenger schema (only if they exist in public) -DO $$ -DECLARE - e text; -BEGIN - FOR e IN - SELECT typname FROM pg_type - JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace - WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e' - LOOP - EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); - END LOOP; -EXCEPTION WHEN others THEN NULL; -END $$; - --- Move tables from public to passenger schema (only if they exist in public) -DO $$ -DECLARE - t text; -BEGIN - FOR t IN - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations') - LOOP - EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); - END LOOP; -EXCEPTION WHEN others THEN NULL; -END $$; - --- Add missing columns to Booking -ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT, - ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, - ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, - ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT, - ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), - ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); - --- Add ReturnLegStatus enum and column -DO $$ BEGIN - CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ( - 'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED' - ); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -ALTER TABLE "passenger"."Booking" - ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE'; - --- Add missing columns to other tables -ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT; -ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1; -ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT; -ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); - -ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; -ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; - -CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql deleted file mode 100644 index 12f4a0eb7..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260627_add_journey_booking_id/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Add bookingId to Journey for per-booking segment release -ALTER TABLE "passenger"."Journey" - ADD COLUMN IF NOT EXISTS "bookingId" TEXT; - -CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId"); -CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId"); - --- AddForeignKey (column created above; FK was misplaced in 20260623073543_config) -ALTER TABLE "passenger"."Journey" - DROP CONSTRAINT IF EXISTS "Journey_bookingId_fkey"; -ALTER TABLE "passenger"."Journey" - ADD CONSTRAINT "Journey_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- Ensure JourneySegment cascades on Journey delete -ALTER TABLE "passenger"."JourneySegment" - DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; - -ALTER TABLE "passenger"."JourneySegment" - ADD CONSTRAINT "JourneySegment_journeyId_fkey" - FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql deleted file mode 100644 index 435d95829..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Migration already applied directly to the database. --- This file exists only to satisfy Prisma's migration directory check (P3015). diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql deleted file mode 100644 index 2dff947d2..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260628000000_sync_agent_iamuserid_travel_packages/migration.sql +++ /dev/null @@ -1,153 +0,0 @@ --- Add iamUserId to Agent (migration 20260622000002 was skipped due to missing iam schema) -ALTER TABLE passenger."Agent" ADD COLUMN IF NOT EXISTS "iamUserId" TEXT; - -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'Agent_iamUserId_key' - AND conrelid = 'passenger."Agent"'::regclass - ) THEN - ALTER TABLE passenger."Agent" ADD CONSTRAINT "Agent_iamUserId_key" UNIQUE ("iamUserId"); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS "Agent_iamUserId_idx" ON passenger."Agent"("iamUserId"); - --- Drop old Agent.userId FK and column if they still exist -ALTER TABLE passenger."Agent" DROP CONSTRAINT IF EXISTS "Agent_userId_fkey"; -DROP INDEX IF EXISTS passenger."Agent_userId_key"; -ALTER TABLE passenger."Agent" DROP COLUMN IF EXISTS "userId"; - --- Drop old Passenger.userId FK (column stays as plain nullable string) -ALTER TABLE passenger."Passenger" DROP CONSTRAINT IF EXISTS "Passenger_userId_fkey"; - --- TravelPackage -CREATE TABLE IF NOT EXISTS passenger."TravelPackage" ( - "id" TEXT NOT NULL, - "code" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "status" TEXT NOT NULL DEFAULT 'DRAFT', - "outboundScheduleId" TEXT NOT NULL, - "returnScheduleId" TEXT NOT NULL, - "originStationId" TEXT NOT NULL, - "destinationStationId" TEXT NOT NULL, - "boardingTime" TIMESTAMP(3) NOT NULL, - "departureTime" TIMESTAMP(3) NOT NULL, - "arrivalTime" TIMESTAMP(3) NOT NULL, - "totalCapacity" INTEGER NOT NULL, - "bookedCount" INTEGER NOT NULL DEFAULT 0, - "includedServices" JSONB NOT NULL, - "coachConfiguration" TEXT, - "busTransferIncluded" BOOLEAN NOT NULL DEFAULT false, - "busTransferRoute" TEXT, - "validFrom" TIMESTAMP(3) NOT NULL, - "validUntil" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "TravelPackage_code_key" ON passenger."TravelPackage"("code"); -CREATE INDEX IF NOT EXISTS "TravelPackage_status_validFrom_idx" ON passenger."TravelPackage"("status","validFrom"); - --- PackagePriceTier -CREATE TABLE IF NOT EXISTS passenger."PackagePriceTier" ( - "id" TEXT NOT NULL, - "packageId" TEXT NOT NULL, - "seatType" TEXT NOT NULL, - "label" TEXT NOT NULL, - "priceMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "availableSeats" INTEGER NOT NULL DEFAULT 0, - "bookedSeats" INTEGER NOT NULL DEFAULT 0, - CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "PackagePriceTier_packageId_seatType_key" ON passenger."PackagePriceTier"("packageId","seatType"); - --- PackageBooking -CREATE TABLE IF NOT EXISTS passenger."PackageBooking" ( - "id" TEXT NOT NULL, - "bookingRef" TEXT NOT NULL, - "packageId" TEXT NOT NULL, - "priceTierId" TEXT NOT NULL, - "passengerId" TEXT, - "contactEmail" TEXT, - "contactPhone" TEXT, - "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', - "passengerCount" INTEGER NOT NULL DEFAULT 1, - "totalMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "displayCurrency" TEXT, - "displayTotalMinor" INTEGER, - "promoCode" TEXT, - "source" TEXT NOT NULL DEFAULT 'WEB', - "paidAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "PackageBooking_bookingRef_key" ON passenger."PackageBooking"("bookingRef"); -CREATE INDEX IF NOT EXISTS "PackageBooking_packageId_status_idx" ON passenger."PackageBooking"("packageId","status"); - --- PackageBookingPassenger -CREATE TABLE IF NOT EXISTS passenger."PackageBookingPassenger" ( - "id" TEXT NOT NULL, - "bookingId" TEXT NOT NULL, - "passengerName" TEXT NOT NULL, - "dateOfBirth" TIMESTAMP(3), - "idDocumentType" TEXT, - "idDocumentNumber" TEXT, - "passportNumber" TEXT, - "passportCountry" TEXT, - "seatLabel" TEXT, - CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id") -); - --- PackagePaymentIntent -CREATE TABLE IF NOT EXISTS passenger."PackagePaymentIntent" ( - "id" TEXT NOT NULL, - "packageBookingId" TEXT NOT NULL, - "amountMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "method" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'REQUIRES_ACTION', - "providerRef" TEXT, - "paidAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id") -); -CREATE UNIQUE INDEX IF NOT EXISTS "PackagePaymentIntent_packageBookingId_key" ON passenger."PackagePaymentIntent"("packageBookingId"); - --- Foreign keys -ALTER TABLE passenger."TravelPackage" - ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" - FOREIGN KEY ("outboundScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."TravelPackage" - ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" - FOREIGN KEY ("returnScheduleId") REFERENCES passenger."TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackagePriceTier" - ADD CONSTRAINT "PackagePriceTier_packageId_fkey" - FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBooking" - ADD CONSTRAINT "PackageBooking_packageId_fkey" - FOREIGN KEY ("packageId") REFERENCES passenger."TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBooking" - ADD CONSTRAINT "PackageBooking_priceTierId_fkey" - FOREIGN KEY ("priceTierId") REFERENCES passenger."PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBooking" - ADD CONSTRAINT "PackageBooking_passengerId_fkey" - FOREIGN KEY ("passengerId") REFERENCES passenger."Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE passenger."PackageBookingPassenger" - ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -ALTER TABLE passenger."PackagePaymentIntent" - ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" - FOREIGN KEY ("packageBookingId") REFERENCES passenger."PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql deleted file mode 100644 index 545b6a5b4..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260628000001_add_package_status_enum/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Create PackageStatus enum -DO $$ BEGIN - CREATE TYPE passenger."PackageStatus" AS ENUM ('DRAFT','ACTIVE','SOLD_OUT','EXPIRED','CANCELLED'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$; - --- Drop default, cast column to enum, restore default -ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" DROP DEFAULT; -ALTER TABLE passenger."TravelPackage" - ALTER COLUMN "status" TYPE passenger."PackageStatus" - USING "status"::passenger."PackageStatus"; -ALTER TABLE passenger."TravelPackage" ALTER COLUMN "status" SET DEFAULT 'DRAFT'::passenger."PackageStatus"; diff --git a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql deleted file mode 100644 index 04e2a7610..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260629000000_add_excess_baggage_charge/migration.sql +++ /dev/null @@ -1,42 +0,0 @@ --- CreateTable -CREATE TABLE "passenger"."ExcessBaggageCharge" ( - "id" TEXT NOT NULL, - "bookingId" TEXT NOT NULL, - "agentId" TEXT NOT NULL, - "excessWeightKg" INTEGER NOT NULL, - "feePerKgMinor" INTEGER NOT NULL, - "totalMinor" INTEGER NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'ETB', - "status" TEXT NOT NULL DEFAULT 'PENDING', - "paymentToken" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "paidAt" TIMESTAMP(3), - "waivedBy" TEXT, - "waivedReason" TEXT, - "contactPhone" TEXT, - "contactEmail" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "passenger"."ExcessBaggageCharge"("paymentToken"); - --- CreateIndex -CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "passenger"."ExcessBaggageCharge"("bookingId"); - --- CreateIndex -CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "passenger"."ExcessBaggageCharge"("paymentToken"); - --- CreateIndex -CREATE INDEX "ExcessBaggageCharge_status_idx" ON "passenger"."ExcessBaggageCharge"("status"); - --- AddForeignKey -ALTER TABLE "passenger"."ExcessBaggageCharge" - ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" - FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") - ON DELETE RESTRICT ON UPDATE CASCADE; - --- Seed default paymentToken using gen_random_uuid() for any rows that may exist -UPDATE "passenger"."ExcessBaggageCharge" SET "paymentToken" = gen_random_uuid()::text WHERE "paymentToken" = ''; diff --git a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql deleted file mode 100644 index 34a2e2caa..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql similarity index 79% rename from apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql index 4ae48ea16..d4d7cbe42 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260605195213_init/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260630074725_init/migration.sql @@ -22,11 +22,14 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD'); -- CreateEnum CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED'); +-- CreateEnum +CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); + -- CreateEnum CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); -- CreateEnum -CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI'); +CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI', 'DMONEY'); -- CreateEnum CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED'); @@ -58,6 +61,9 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER -- CreateEnum CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB'); +-- CreateEnum +CREATE TYPE "PackageStatus" AS ENUM ('DRAFT', 'ACTIVE', 'SOLD_OUT', 'EXPIRED', 'CANCELLED'); + -- CreateTable CREATE TABLE "CoachType" ( "id" TEXT NOT NULL, @@ -130,9 +136,11 @@ CREATE TABLE "Session" ( -- CreateTable CREATE TABLE "Passenger" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "userId" TEXT, + "iamUserId" TEXT, "defaultTravelerProfileId" TEXT, "preferredLanguage" TEXT, + "blockedUntil" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "Passenger_pkey" PRIMARY KEY ("id") @@ -143,6 +151,7 @@ CREATE TABLE "TravelerProfile" ( "id" TEXT NOT NULL, "passengerId" TEXT NOT NULL, "fullName" TEXT NOT NULL, + "gender" TEXT, "relationship" TEXT NOT NULL, "dateOfBirth" TIMESTAMP(3), "nationalId" TEXT, @@ -161,7 +170,6 @@ CREATE TABLE "Station" ( "countryCode" TEXT, "sequence" INTEGER NOT NULL DEFAULT 0, "isOperational" BOOLEAN NOT NULL DEFAULT true, - "timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa', "lat" DECIMAL(9,6), "lng" DECIMAL(9,6), @@ -314,6 +322,7 @@ CREATE TABLE "Booking" ( "bookingRef" TEXT NOT NULL, "passengerId" TEXT NOT NULL, "scheduleId" TEXT NOT NULL, + "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', "status" "BookingStatus" NOT NULL DEFAULT 'DRAFT', "currency" TEXT NOT NULL DEFAULT 'ETB', "totalMinor" INTEGER NOT NULL, @@ -321,13 +330,29 @@ CREATE TABLE "Booking" ( "childCount" INTEGER NOT NULL DEFAULT 0, "displayCurrency" "Currency", "displayTotalMinor" INTEGER, - "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', + "returnScheduleId" TEXT, + "returnOriginStationId" TEXT, + "returnDestinationStationId" TEXT, + "returnHoldId" TEXT, + "returnSeatClassId" TEXT, + "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', + "leg2ScheduleId" TEXT, + "leg2OriginStationId" TEXT, + "leg2DestinationStationId" TEXT, + "leg2SeatClassId" TEXT, + "returnLeg2ScheduleId" TEXT, + "returnLeg2OriginStationId" TEXT, + "returnLeg2DestStationId" TEXT, + "returnLeg2SeatClassId" TEXT, + "outboundBoardedAt" TIMESTAMP(3), + "returnBoardedAt" TIMESTAMP(3), "contactEmail" TEXT, "contactPhone" TEXT, "userAgent" TEXT, "source" TEXT NOT NULL DEFAULT 'WEB', "promoCode" TEXT, "paidAt" TIMESTAMP(3), + "paymentReminderSentAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -339,6 +364,8 @@ CREATE TABLE "BookingSeat" ( "id" TEXT NOT NULL, "bookingId" TEXT NOT NULL, "seatId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "scheduleId" TEXT, "passengerName" TEXT NOT NULL, "dateOfBirth" TIMESTAMP(3), "passengerCategory" "PassengerCategory" NOT NULL DEFAULT 'ADULT', @@ -438,7 +465,11 @@ CREATE TABLE "Ticket" ( "id" TEXT NOT NULL, "bookingId" TEXT NOT NULL, "bookingRef" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'CONFIRMED', + "passengerName" TEXT NOT NULL, + "seatId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "scheduleId" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', "qrPayload" TEXT NOT NULL, "barcodePayload" TEXT, "pdfUrl" TEXT, @@ -446,20 +477,11 @@ CREATE TABLE "Ticket" ( "issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "validatedAt" TIMESTAMP(3), "validatorId" TEXT, + "boardedAt" TIMESTAMP(3), CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "TicketSeat" ( - "id" TEXT NOT NULL, - "ticketId" TEXT NOT NULL, - "seatId" TEXT NOT NULL, - "seatIndex" INTEGER NOT NULL DEFAULT 0, - - CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id") -); - -- CreateTable CREATE TABLE "LoyaltyAccount" ( "id" TEXT NOT NULL, @@ -679,7 +701,7 @@ CREATE TABLE "SupportMessage" ( -- CreateTable CREATE TABLE "UserPreferences" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT NOT NULL, "pushEnabled" BOOLEAN NOT NULL DEFAULT true, "emailEnabled" BOOLEAN NOT NULL DEFAULT true, "smsEnabled" BOOLEAN NOT NULL DEFAULT false, @@ -699,7 +721,7 @@ CREATE TABLE "UserPreferences" ( -- CreateTable CREATE TABLE "Device" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT NOT NULL, "platform" "DevicePlatform" NOT NULL, "name" TEXT NOT NULL, "pushToken" TEXT, @@ -727,6 +749,7 @@ CREATE TABLE "SavedRoute" ( CREATE TABLE "Journey" ( "id" TEXT NOT NULL, "passengerId" TEXT NOT NULL, + "bookingId" TEXT, "status" TEXT NOT NULL, "totalMinor" INTEGER NOT NULL, "currency" TEXT NOT NULL DEFAULT 'ETB', @@ -796,7 +819,7 @@ CREATE TABLE "RouteStop" ( "routeId" TEXT NOT NULL, "stationId" TEXT NOT NULL, "sequence" INTEGER NOT NULL, - "distanceKm" INTEGER, + "distanceKm" DOUBLE PRECISION, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "RouteStop_pkey" PRIMARY KEY ("id") @@ -820,10 +843,27 @@ CREATE TABLE "RouteFareRule" ( CONSTRAINT "RouteFareRule_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "SegmentFareRule" ( + "id" TEXT NOT NULL, + "routeId" TEXT NOT NULL, + "originStopSequence" INTEGER NOT NULL, + "destinationStopSequence" INTEGER NOT NULL, + "seatClassId" TEXT NOT NULL, + "baseFareMinor" INTEGER NOT NULL, + "nationality" TEXT, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "validFrom" TIMESTAMP(3) NOT NULL, + "validUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "Agent" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT, "agentCode" TEXT NOT NULL, "stationId" TEXT, "commissionRate" INTEGER NOT NULL DEFAULT 5, @@ -910,6 +950,7 @@ CREATE TABLE "GateValidationLog" ( "ticketId" TEXT NOT NULL, "validatorId" TEXT NOT NULL, "gateId" TEXT, + "leg" TEXT, "status" TEXT NOT NULL, "reason" TEXT, "validatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -943,10 +984,32 @@ CREATE TABLE "BaggageBooking" ( CONSTRAINT "BaggageBooking_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "ExcessBaggageCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "excessWeightKg" INTEGER NOT NULL, + "feePerKgMinor" INTEGER NOT NULL, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "paidAt" TIMESTAMP(3), + "waivedBy" TEXT, + "waivedReason" TEXT, + "contactPhone" TEXT, + "contactEmail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ExcessBaggageCharge_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "AuditLog" ( "id" TEXT NOT NULL, - "userId" TEXT, + "iamUserId" TEXT, "action" TEXT NOT NULL, "entityType" TEXT NOT NULL, "entityId" TEXT, @@ -1014,7 +1077,7 @@ CREATE TABLE "FraudRule" ( -- CreateTable CREATE TABLE "FraudAlert" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, + "iamUserId" TEXT NOT NULL, "eventType" TEXT NOT NULL, "triggeredRules" TEXT[], "context" JSONB NOT NULL, @@ -1077,7 +1140,7 @@ CREATE TABLE "FaydaVerificationSession" ( "id" TEXT NOT NULL, "state" TEXT NOT NULL, "codeVerifier" TEXT NOT NULL, - "purpose" TEXT NOT NULL DEFAULT 'PURCHASE', + "purpose" TEXT NOT NULL DEFAULT 'VERIFY', "platform" TEXT NOT NULL DEFAULT 'WEB', "saveToAccount" BOOLEAN NOT NULL DEFAULT false, "status" TEXT NOT NULL DEFAULT 'PENDING', @@ -1087,12 +1150,137 @@ CREATE TABLE "FaydaVerificationSession" ( "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "expiresAt" TIMESTAMP(3) NOT NULL, "completedAt" TIMESTAMP(3), - "userId" TEXT, + "iamUserId" TEXT, "bookingId" TEXT, CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TravelPackage" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "status" "PackageStatus" NOT NULL DEFAULT 'DRAFT', + "outboundScheduleId" TEXT NOT NULL, + "returnScheduleId" TEXT NOT NULL, + "originStationId" TEXT NOT NULL, + "destinationStationId" TEXT NOT NULL, + "boardingTime" TIMESTAMP(3) NOT NULL, + "departureTime" TIMESTAMP(3) NOT NULL, + "arrivalTime" TIMESTAMP(3) NOT NULL, + "totalCapacity" INTEGER NOT NULL, + "bookedCount" INTEGER NOT NULL DEFAULT 0, + "includedServices" JSONB NOT NULL, + "coachConfiguration" TEXT, + "busTransferIncluded" BOOLEAN NOT NULL DEFAULT false, + "busTransferRoute" TEXT, + "validFrom" TIMESTAMP(3) NOT NULL, + "validUntil" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TravelPackage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackagePriceTier" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "seatType" TEXT NOT NULL, + "label" TEXT NOT NULL, + "priceMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "availableSeats" INTEGER NOT NULL DEFAULT 0, + "bookedSeats" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "PackagePriceTier_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackageBooking" ( + "id" TEXT NOT NULL, + "bookingRef" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "priceTierId" TEXT NOT NULL, + "passengerId" TEXT, + "contactEmail" TEXT, + "contactPhone" TEXT, + "status" "BookingStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', + "passengerCount" INTEGER NOT NULL DEFAULT 1, + "totalMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "displayCurrency" "Currency", + "displayTotalMinor" INTEGER, + "promoCode" TEXT, + "source" TEXT NOT NULL DEFAULT 'WEB', + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PackageBooking_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackageBookingPassenger" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "passengerName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3), + "idDocumentType" "IdDocumentType", + "idDocumentNumber" TEXT, + "passportNumber" TEXT, + "passportCountry" TEXT, + "seatLabel" TEXT, + + CONSTRAINT "PackageBookingPassenger_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackagePaymentIntent" ( + "id" TEXT NOT NULL, + "packageBookingId" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "method" "PaymentMethodType" NOT NULL, + "status" "PaymentIntentStatus" NOT NULL DEFAULT 'REQUIRES_ACTION', + "providerRef" TEXT, + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PackagePaymentIntent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PackageInquiry" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "priceTierId" TEXT, + "travelerCount" INTEGER NOT NULL, + "contactName" TEXT NOT NULL, + "contactEmail" TEXT, + "contactPhone" TEXT, + "notes" TEXT, + "status" TEXT NOT NULL DEFAULT 'NEW', + "enquiredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PackageInquiry_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId"); @@ -1114,15 +1302,24 @@ CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); -- CreateIndex CREATE UNIQUE INDEX "Passenger_userId_key" ON "Passenger"("userId"); +-- CreateIndex +CREATE UNIQUE INDEX "Passenger_iamUserId_key" ON "Passenger"("iamUserId"); + -- CreateIndex CREATE INDEX "Passenger_userId_idx" ON "Passenger"("userId"); +-- CreateIndex +CREATE INDEX "Passenger_iamUserId_idx" ON "Passenger"("iamUserId"); + -- CreateIndex CREATE UNIQUE INDEX "Station_code_key" ON "Station"("code"); -- CreateIndex CREATE INDEX "Station_city_countryCode_idx" ON "Station"("city", "countryCode"); +-- CreateIndex +CREATE INDEX "Station_sequence_idx" ON "Station"("sequence"); + -- CreateIndex CREATE UNIQUE INDEX "Train_number_key" ON "Train"("number"); @@ -1141,6 +1338,9 @@ CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number"); -- CreateIndex CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId"); +-- CreateIndex +CREATE INDEX "Coach_sequence_idx" ON "Coach"("sequence"); + -- CreateIndex CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId"); @@ -1165,6 +1365,9 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef"); -- CreateIndex CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); +-- CreateIndex +CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); + -- CreateIndex CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type"); @@ -1187,13 +1390,10 @@ CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"( CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId"); -- CreateIndex -CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId"); +CREATE INDEX "Ticket_bookingId_idx" ON "Ticket"("bookingId"); -- CreateIndex -CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId"); - --- CreateIndex -CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId"); +CREATE INDEX "Ticket_seatId_idx" ON "Ticket"("seatId"); -- CreateIndex CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId"); @@ -1208,7 +1408,10 @@ CREATE INDEX "WalletAccount_passengerId_idx" ON "WalletAccount"("passengerId"); CREATE UNIQUE INDEX "Promotion_code_key" ON "Promotion"("code"); -- CreateIndex -CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId"); +CREATE UNIQUE INDEX "UserPreferences_iamUserId_key" ON "UserPreferences"("iamUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Journey_bookingId_key" ON "Journey"("bookingId"); -- CreateIndex CREATE INDEX "OtpCode_email_phone_idx" ON "OtpCode"("email", "phone"); @@ -1232,11 +1435,20 @@ CREATE UNIQUE INDEX "RouteStop_routeId_sequence_key" ON "RouteStop"("routeId", " CREATE INDEX "RouteFareRule_routeId_seatClassId_idx" ON "RouteFareRule"("routeId", "seatClassId"); -- CreateIndex -CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId"); +CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality"); + +-- CreateIndex +CREATE UNIQUE INDEX "Agent_iamUserId_key" ON "Agent"("iamUserId"); -- CreateIndex CREATE UNIQUE INDEX "Agent_agentCode_key" ON "Agent"("agentCode"); +-- CreateIndex +CREATE INDEX "Agent_iamUserId_idx" ON "Agent"("iamUserId"); + -- CreateIndex CREATE UNIQUE INDEX "AgentBooking_bookingId_key" ON "AgentBooking"("bookingId"); @@ -1262,7 +1474,19 @@ CREATE INDEX "GateValidationLog_validatorId_idx" ON "GateValidationLog"("validat CREATE INDEX "BaggageBooking_bookingId_idx" ON "BaggageBooking"("bookingId"); -- CreateIndex -CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt"); +CREATE UNIQUE INDEX "ExcessBaggageCharge_paymentToken_key" ON "ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_bookingId_idx" ON "ExcessBaggageCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_paymentToken_idx" ON "ExcessBaggageCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "ExcessBaggageCharge_status_idx" ON "ExcessBaggageCharge"("status"); + +-- CreateIndex +CREATE INDEX "AuditLog_iamUserId_createdAt_idx" ON "AuditLog"("iamUserId", "createdAt"); -- CreateIndex CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId"); @@ -1280,7 +1504,7 @@ CREATE INDEX "OperationalReport_reportType_dateFrom_idx" ON "OperationalReport"( CREATE UNIQUE INDEX "FraudRule_type_key" ON "FraudRule"("type"); -- CreateIndex -CREATE INDEX "FraudAlert_userId_createdAt_idx" ON "FraudAlert"("userId", "createdAt"); +CREATE INDEX "FraudAlert_iamUserId_createdAt_idx" ON "FraudAlert"("iamUserId", "createdAt"); -- CreateIndex CREATE INDEX "FraudAlert_acknowledged_idx" ON "FraudAlert"("acknowledged"); @@ -1307,7 +1531,7 @@ CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("de CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state"); -- CreateIndex -CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId"); +CREATE INDEX "FaydaVerificationSession_iamUserId_idx" ON "FaydaVerificationSession"("iamUserId"); -- CreateIndex CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId"); @@ -1318,6 +1542,30 @@ CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"( -- CreateIndex CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt"); +-- CreateIndex +CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "TravelPackage_code_key" ON "TravelPackage"("code"); + +-- CreateIndex +CREATE INDEX "TravelPackage_status_validFrom_idx" ON "TravelPackage"("status", "validFrom"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackagePriceTier_packageId_seatType_key" ON "PackagePriceTier"("packageId", "seatType"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackageBooking_bookingRef_key" ON "PackageBooking"("bookingRef"); + +-- CreateIndex +CREATE INDEX "PackageBooking_packageId_status_idx" ON "PackageBooking"("packageId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "PackagePaymentIntent_packageBookingId_key" ON "PackagePaymentIntent"("packageBookingId"); + +-- CreateIndex +CREATE INDEX "PackageInquiry_packageId_idx" ON "PackageInquiry"("packageId"); + -- AddForeignKey ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1325,7 +1573,7 @@ ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Passenger" ADD CONSTRAINT "Passenger_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1372,6 +1620,9 @@ ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("pa -- AddForeignKey ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1388,10 +1639,7 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1432,15 +1680,12 @@ ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY -- AddForeignKey ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1457,7 +1702,10 @@ ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_routeId_fkey" FOREIGN ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -1484,13 +1732,37 @@ ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ExcessBaggageCharge" ADD CONSTRAINT "ExcessBaggageCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_outboundScheduleId_fkey" FOREIGN KEY ("outboundScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TravelPackage" ADD CONSTRAINT "TravelPackage_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBooking" ADD CONSTRAINT "PackageBooking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageBookingPassenger" ADD CONSTRAINT "PackageBookingPassenger_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackagePaymentIntent" ADD CONSTRAINT "PackagePaymentIntent_packageBookingId_fkey" FOREIGN KEY ("packageBookingId") REFERENCES "PackageBooking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PackageInquiry" ADD CONSTRAINT "PackageInquiry_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/scripts/resolve-migrations.sh b/apps/edr-passenger-api/scripts/resolve-migrations.sh new file mode 100644 index 000000000..cc34c747e --- /dev/null +++ b/apps/edr-passenger-api/scripts/resolve-migrations.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +echo "🔍 Checking for failed migrations..." + +# Mark legacy migrations as applied +npx prisma migrate resolve --applied "20240100000000_fix_failed_migration_state" || true +npx prisma migrate resolve --applied "20240101000000_individual_tickets_no_timezone" || true +npx prisma migrate resolve --applied "20240102000000_drop_ticket_column_defaults" || true +npx prisma migrate resolve --applied "20241201000000_remove_station_timezone" || true +npx prisma migrate resolve --applied "20260101000000_add_configurable_fare_system" || true + +echo "✅ Migration resolution complete" \ No newline at end of file diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts index 3f1dc161f..cff5403da 100644 --- a/apps/edr-passenger-api/src/common/audit.service.ts +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -1,9 +1,10 @@ -import { Injectable, Inject, Optional } from '@nestjs/common'; +import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { PrismaService } from './prisma.service'; @Injectable() export class AuditService { + private readonly logger = new Logger(AuditService.name); constructor( private prisma: PrismaService, @Optional() @Inject(REQUEST) private request?: any, @@ -34,7 +35,7 @@ export class AuditService { }, }); } catch (error) { - console.error('Failed to log audit event:', error); + this.logger.error('Failed to log audit event:', error); // Don't throw - audit logging should not break main operations } } @@ -74,11 +75,20 @@ export class AuditService { where.entityType = filters.entityType; } - return this.prisma.auditLog.findMany({ - where, - orderBy: { createdAt: 'desc' }, - take: 500, - }); + const limit = Math.min(filters.limit ?? 50, 200); + const offset = filters.offset ?? 0; + + const [data, total] = await Promise.all([ + this.prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }), + this.prisma.auditLog.count({ where }), + ]); + + return { data, total, limit, offset }; } async getLog(id: string) { diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 43ddecddf..7a450bfaf 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { } async canActivate(context: ExecutionContext): Promise { + if (context.getType() !== 'http') { + return true; + } + const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] = await Promise.all([ this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT), diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index 7810c6d94..39d492b2c 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -41,9 +41,7 @@ export class HttpExceptionFilter implements ExceptionFilter { this.logger.error( `${request.method} ${request.url} -> ${status}`, exception instanceof Error ? exception.stack : JSON.stringify(exception), - ); - console.error('Full error details:', exception); - } else { + ); } else { this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`); } diff --git a/apps/edr-passenger-api/src/common/i18n/i18n.service.ts b/apps/edr-passenger-api/src/common/i18n/i18n.service.ts index 9c3eee5fc..c53a1e033 100644 --- a/apps/edr-passenger-api/src/common/i18n/i18n.service.ts +++ b/apps/edr-passenger-api/src/common/i18n/i18n.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; @@ -6,6 +6,7 @@ type TranslationMap = Record; @Injectable() export class I18nService { + private readonly logger = new Logger(I18nService.name); private translations: Map = new Map(); private readonly supportedLocales = ['en', 'am', 'fr', 'om']; private readonly defaultLocale = 'en'; @@ -21,7 +22,7 @@ export class I18nService { const content = fs.readFileSync(filePath, 'utf-8'); this.translations.set(locale, JSON.parse(content)); } catch (err) { - console.warn(`Failed to load translation file for locale: ${locale}`); + this.logger.warn(`Failed to load translation file for locale: ${locale}`); } } } diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts index d8c4b1868..a25289159 100644 --- a/apps/edr-passenger-api/src/config/fayda.config.ts +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -23,7 +23,10 @@ export interface FaydaConfig { authorizationEndpoint: string; tokenEndpoint: string; userInfoEndpoint: string; + /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; + /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + webRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -73,6 +76,7 @@ export default registerAs('fayda', (): FaydaConfig => { const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; + const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; if (!enabled) { return { enabled: false, @@ -81,6 +85,7 @@ export default registerAs('fayda', (): FaydaConfig => { tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, + webRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -111,6 +116,7 @@ export default registerAs('fayda', (): FaydaConfig => { tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, + webRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index cd583517c..4dd131f3e 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -548,7 +548,7 @@ export class PassengerAuthService { await this.dataSource.query(`DELETE FROM iam.users WHERE id = $1`, [iamUserId]); } catch (err) { - console.error('[PassengerAuthService] IAM compensating cleanup failed for', email, (err as Error).message); + this.logger.error(`[PassengerAuthService] IAM compensating cleanup failed for ${email}`, (err as Error).message); } } } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 52603e776..4062acddc 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -1,5 +1,5 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator'; -import { Type } from 'class-transformer'; +import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; @@ -9,7 +9,11 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string; @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; - @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; + @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' }) + @IsDateString() + @Transform(({ value }) => value) + @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) + dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string; @ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string; @@ -38,9 +42,12 @@ export class RoundTripPassengerDto { @ApiProperty({ example: '1990-05-15', - description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)' - }) - @IsDateString() dateOfBirth: string; + description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' + }) + @IsDateString() + @Transform(({ value }) => value) + @MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' }) + dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 0cc75a438..24f9981fc 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -206,6 +206,13 @@ export class FleetController { return this.service.listCoaches(dto); } + @Get('coaches/utilization') + @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' }) + @ApiResponse({ status: 200, description: 'Coach utilization data' }) + getCoachUtilization() { + return this.service.getCoachUtilization(); + } + @Get('coaches/:id') @ApiOperation({ summary: 'Get single coach with seat layout' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 410db914e..2f2a76f48 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -1,5 +1,6 @@ import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; export class CreateTrainDto { @ApiProperty({ example: '301', description: 'Unique train service number' }) @IsString() number: string; @@ -7,7 +8,7 @@ export class CreateTrainDto { @ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string; @ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string; @ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string; - @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @IsBoolean() isActive?: boolean; } export class CreateCoachDto { @@ -31,7 +32,7 @@ export class CreateCoachDto { @IsOptional() @IsInt() sequence?: number; } -export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { +export class UpdateCoachDto extends PartialType(CreateCoachDto) { @ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering' }) @IsOptional() @IsInt() sequence?: number; } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index dc71dd4b4..ebec606c6 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -317,7 +317,10 @@ export class FleetService { } getTrains() { - return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } }); + return this.prisma.train.findMany({ + where: { isActive: true }, + include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } }, + }); } createTrain(dto: CreateTrainDto) { @@ -462,6 +465,7 @@ export class FleetService { return this.prisma.coach.update({ where: { id }, data: { + number: dto.number, arrangement: dto.arrangement, capacity: dto.capacity, status: dto.status, @@ -540,6 +544,7 @@ export class FleetService { ]); if (!schedule) throw new NotFoundException('Schedule not found'); if (!coach) throw new NotFoundException('Coach not found'); + if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active'); return this.prisma.coachAssignment.create({ data: dto }); } @@ -593,6 +598,58 @@ export class FleetService { }; } + async getCoachUtilization() { + const coaches = await this.prisma.coach.findMany({ + include: { + coachType: true, + seats: { select: { id: true, status: true } }, + assignments: { + include: { + schedule: { + select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } }, + }, + }, + orderBy: { schedule: { departureAt: 'desc' } }, + take: 10, + }, + }, + orderBy: { sequence: 'asc' }, + }); + + return coaches.map((coach) => { + const totalSeats = coach.seats.length; + const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length; + const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length; + const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length; + const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length; + const totalAssignments = coach.assignments.length; + const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0); + const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0; + + return { + id: coach.id, + number: coach.number, + sequence: coach.sequence, + coachType: coach.coachType?.name, + status: coach.status, + totalSeats, + availableSeats, + bookedSeats, + blockedSeats, + maintenanceSeats, + utilizationRate, + totalAssignments, + totalBookings, + recentSchedules: coach.assignments.slice(0, 5).map((a) => ({ + scheduleId: a.scheduleId, + departureAt: a.schedule.departureAt, + scheduleStatus: a.schedule.status, + bookings: (a.schedule as any)._count?.bookings ?? 0, + })), + }; + }); + } + async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), diff --git a/apps/edr-passenger-api/src/modules/health/health.controller.ts b/apps/edr-passenger-api/src/modules/health/health.controller.ts index 6cc50e24e..a918fa76c 100644 --- a/apps/edr-passenger-api/src/modules/health/health.controller.ts +++ b/apps/edr-passenger-api/src/modules/health/health.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PrismaService } from '../../common/prisma.service'; +import { Response } from 'express'; @ApiTags('Health') @Controller('health') @@ -20,17 +21,17 @@ export class HealthController { @Get('ready') @IsPublic() @ApiOperation({ summary: 'Readiness probe — checks database connectivity' }) - async readiness() { + async readiness(@Res() res: Response) { const start = Date.now(); try { await this.prisma.$queryRaw`SELECT 1`; - return { + return res.status(HttpStatus.OK).json({ status: 'ok', timestamp: new Date().toISOString(), checks: { database: { status: 'ok', latencyMs: Date.now() - start } }, - }; + }); } catch (err) { - return { + return res.status(HttpStatus.SERVICE_UNAVAILABLE).json({ status: 'error', timestamp: new Date().toISOString(), checks: { @@ -40,7 +41,7 @@ export class HealthController { error: err instanceof Error ? err.message : 'Unknown error', }, }, - }; + }); } } diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 04188842d..a2bbbad99 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -26,7 +26,7 @@ export class SmsClientService implements OnApplicationBootstrap { this.logger.log("connected to SMS service"); }) .catch((err) => { - console.error("Error happened at SMS service", err); + this.logger.error('Error happened at SMS service', err); }); } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index b2b7ae631..ea952d98c 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -25,6 +25,7 @@ type IamUserRow = { @Injectable() export class PassengersService { + private readonly logger = new Logger(PassengersService.name); constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, @@ -373,7 +374,7 @@ export class PassengersService { verifiedData = verification.passengerData; } } catch (error) { - console.warn('Fayda verification failed, using manual data:', error); + this.logger.warn('Fayda verification failed, using manual data:', error); } } diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index 291402240..a80c63468 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, @@ -19,6 +20,7 @@ export class PaymentEventsConsumer { constructor(private readonly paymentsService: PaymentsService) {} + @IsPublic() @RabbitSubscribe({ exchange: PAYMENT_EVENTS_EXCHANGE, routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.* @@ -29,6 +31,11 @@ export class PaymentEventsConsumer { }, }) async handle(event: PaymentEvent): Promise { + // Logged the instant RabbitMQ delivers the message, before any DB work — proves the + // payment -> passenger broker connection works even if processing later fails/hangs. + this.logger.log( + `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, + ); try { const result = await this.paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 5c1b5e582..e37cac0d3 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -6,6 +6,7 @@ import { GenerateReportDto, ReportType } from './reports.dto'; @Injectable() export class ReportsService { + private readonly logger = new Logger(ReportsService.name); constructor( private prisma: PrismaService, @InjectDataSource() private dataSource: DataSource, @@ -60,8 +61,6 @@ export class ReportsService { include: { paymentIntent: true } }); - console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`); - const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0); const byPaymentMethod = bookings.reduce((acc, b) => { const method = b.paymentIntent?.method ?? 'UNKNOWN'; diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index 21768ab8b..bce25fea5 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -1,11 +1,11 @@ -import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator'; +import { IsString, IsInt, IsNumber, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number; + @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; } export class CreateRouteDto { @@ -34,7 +34,7 @@ export class CreateRouteDto { export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number; + @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; } export class UpdateRouteDto { @@ -42,4 +42,5 @@ export class UpdateRouteDto { @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index c7998e9cd..2a7254f37 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -34,7 +34,7 @@ export class RoutesService { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, - distanceKm: s.distanceKm, + distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, })), }, }, @@ -81,7 +81,8 @@ export class RoutesService { async updateRoute(id: string, dto: UpdateRouteDto) { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); - return this.prisma.route.update({ + + await this.prisma.route.update({ where: { id }, data: { name: dto.name, @@ -89,6 +90,22 @@ export class RoutesService { active: dto.active, effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, }, + }); + + if (dto.stops && dto.stops.length >= 2) { + await this.prisma.routeStop.deleteMany({ where: { routeId: id } }); + await this.prisma.routeStop.createMany({ + data: dto.stops.map(s => ({ + routeId: id, + stationId: s.stationId, + sequence: s.sequence, + distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, + })), + }); + } + + return this.prisma.route.findUnique({ + where: { id }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); } @@ -128,6 +145,7 @@ export class RoutesService { const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } }); if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`); + if (!station.isOperational) throw new BadRequestException(`Station ${dto.stationId} is not operational`); const existing = await this.prisma.routeStop.findUnique({ where: { routeId_sequence: { routeId, sequence: dto.sequence } }, @@ -135,7 +153,12 @@ export class RoutesService { if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); return this.prisma.routeStop.create({ - data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm }, + data: { + routeId, + stationId: dto.stationId, + sequence: dto.sequence, + distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, + }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index ac55bc14b..6d1091719 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,9 +2,8 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { TripStatus } from '@prisma/client'; @ApiTags('Schedule') @Controller('schedules') diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 4e422f2ad..b6363e085 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -1,7 +1,27 @@ import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client'; + +export enum TripStatus { + SCHEDULED = 'SCHEDULED', + BOARDING = 'BOARDING', + EN_ROUTE = 'EN_ROUTE', + ARRIVED = 'ARRIVED', + CANCELLED = 'CANCELLED', + DELAYED = 'DELAYED', +} + +export enum StopStatus { + COMPLETED = 'COMPLETED', + APPROACHING = 'APPROACHING', + CURRENT = 'CURRENT', + UPCOMING = 'UPCOMING', +} + +export enum PassengerCategory { + ADULT = 'ADULT', + CHILD = 'CHILD', +} export class PlannedStopTimeDto { @ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7a061ac42..66e43d6e9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -100,10 +100,15 @@ export class SchedulesService { const arr = parseEthiopianTime(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); - const route = await this.prisma.route.findUnique({ - where: { id: dto.routeId }, - include: { stops: { orderBy: { sequence: 'asc' } } }, - }); + const [train, route] = await Promise.all([ + this.prisma.train.findUnique({ where: { id: dto.trainId } }), + this.prisma.route.findUnique({ + where: { id: dto.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }), + ]); + if (!train) throw new NotFoundException('Train not found'); + if (!train.isActive) throw new BadRequestException('Train is not active'); if (!route) throw new NotFoundException('Route not found'); if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); @@ -247,10 +252,15 @@ export class SchedulesService { const arr = parseEthiopianTime(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); - const route = await this.prisma.route.findUnique({ - where: { id: dto.routeId }, - include: { stops: { orderBy: { sequence: 'asc' } } }, - }); + const [train, route] = await Promise.all([ + this.prisma.train.findUnique({ where: { id: dto.trainId } }), + this.prisma.route.findUnique({ + where: { id: dto.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }), + ]); + if (!train) throw new NotFoundException('Train not found'); + if (!train.isActive) throw new BadRequestException('Train is not active'); if (!route) throw new NotFoundException('Route not found'); if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); @@ -540,6 +550,8 @@ export class SchedulesService { const coachIds = coaches.map(c => c.coachId); const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); + const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE'); + if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e14db6b0a..08687d291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -9,6 +9,30 @@ import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; +// Shape returned by the heavy schedule include used throughout this service +type ScheduleWithIncludes = { + id: string; + routeId: string | null; + departureAt: Date; + arrivalAt: Date; + status: string; + train: any; + originStation: any; + destinationStation: any; + stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; + coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; +}; + +const SCHEDULE_INCLUDE = { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, +} as const; + @Injectable() export class SearchService { constructor( @@ -124,7 +148,7 @@ export class SearchService { if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); const totalPassengers = adultCount + (childCount ?? 0); @@ -139,30 +163,16 @@ export class SearchService { ], stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult( - schedule, - originStationId, - destinationStationId, - totalPassengers, - nationality, - ); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } private async searchSchedules( @@ -185,29 +195,18 @@ export class SearchService { departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } // ── Transit search ───────────────────────────────────────────────────────── - // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) - // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes - // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; @@ -219,82 +218,67 @@ export class SearchService { childCount?: number, nationality?: string, ) { - // Find all stations that can serve as transit points: - // they must be a stop after origin on some schedule AND - // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); - // Load all schedules on this date that pass through origin - const leg1Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: dayStart, lt: dayEnd }, - stopTimes: { some: { stationId: originStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + // Load leg1 and all potential leg2 candidates in one parallel round-trip + // instead of firing a separate DB query per transit stop. + const [leg1Schedules, allCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, }, - }, - }); + include: SCHEDULE_INCLUDE, + }), + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: leg2WindowEnd }, + }, + include: SCHEDULE_INCLUDE, + }), + ]); const results: any[] = []; - for (const leg1 of leg1Schedules) { - const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; - // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( - (s: any) => s.sequence > originStop.sequence, + s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - // leg1 must NOT already contain the final destination - const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); - if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules + const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; - // Find leg2 schedules departing from the transit station within the connection window, - // and reaching the final destination. Search up to the next calendar day to handle - // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); - const leg2Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: connWindowStart, lte: connWindowEnd }, - stopTimes: { some: { stationId: transitStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + // Filter from pre-loaded candidates in memory — no extra DB query + const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { + const dep = new Date(s.departureAt).getTime(); + return dep >= connWindowStart.getTime() + && dep <= connWindowEnd.getTime() + && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { - const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); - const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; - // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), @@ -326,7 +310,6 @@ export class SearchService { displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, - // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: @@ -339,19 +322,37 @@ export class SearchService { return results; } - // Builds the same result shape as searchSchedules for a single schedule+leg, - // extracted so both direct and transit paths share identical output. private async buildScheduleResult( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Collect all valid seat IDs upfront for a single batch availability check + const allValidSeatIds = schedule.coachAssignments.flatMap(a => + a.coach.seats + .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) + .map((s: any) => s.id as string) + ); + + // Run availability batch and fare calculation in parallel + const [freeSeats, faresByClass] = await Promise.all([ + this.segmentsService.getFreeSeatIds( + schedule.id, + allValidSeatIds, + schedule.stopTimes, + originStop.sequence, + destStop.sequence, + ), + this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + ]); + + // Compute per-class availability using the pre-computed free seat set const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; @@ -362,8 +363,7 @@ export class SearchService { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) count++; + if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); @@ -374,15 +374,13 @@ export class SearchService { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) available++; + if (freeSeats.has(seat.id)) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -400,8 +398,8 @@ export class SearchService { durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), displayCurrency, @@ -500,46 +498,31 @@ export class SearchService { } private async calculateFaresForSegment( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - const seatClassIds: string[] = Array.from( - new Set( - schedule.coachAssignments - .flatMap((a: any) => a.coach.coachType?.seatClasses || []) - .map((sc: any) => sc.id) - .filter((id: any) => id) - ) - ); - - if (seatClassIds.length === 0) { - console.log(`No seat classes assigned to schedule ${schedule.id}`); - return []; + // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany + const seatClassMap = new Map(); + for (const a of schedule.coachAssignments) { + for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + } } + const seatClasses = Array.from(seatClassMap.values()) + .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: seatClassIds } - }, - orderBy: { baseFareMinor: 'asc' }, - }); - - if (seatClasses.length === 0) { - console.log(`No active seat classes for schedule ${schedule.id}`); - return []; - } + if (seatClasses.length === 0) return []; if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, + routeId: schedule.routeId!, originStationId, destinationStationId, seatClassId: sc.id, @@ -552,8 +535,7 @@ export class SearchService { displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; - } catch (error) { - console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); + } catch { return null; } }), @@ -562,36 +544,32 @@ export class SearchService { const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); - if (validResults.length > 0) { - return validResults; - } + if (validResults.length > 0) return validResults; } - const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); - const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); + // Fallback: use station codes from already-loaded stopTimes when available + const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); + const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); + const originCode = originStop?.station?.code; + const destCode = destStop?.station?.code; - if (originStation && destStation) { - const segmentRoute = `${originStation.code}-${destStation.code}`; + if (originCode && destCode) { + const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: seatClassIds }, + seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: rule.baseFareMinor, displayCurrency, displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), @@ -599,20 +577,20 @@ export class SearchService { } } - console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } - private async buildCoachTypeDetails( - schedule: any, + // buildCoachTypeDetails is pure in-memory — no async needed + private buildCoachTypeDetails( + schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, - ): Promise; - }>> { + }> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } @@ -682,14 +660,6 @@ export class SearchService { return fare.baseFarePerPassengerMinor; } - private getDefaultFareForClass(_className: string): never { - throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); - } - - private defaultFare(_seatClassName: string): never { - throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); - } - private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 3f2fafebf..10fcfe0cf 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -6,12 +6,16 @@ export class SeatClassesService { constructor(private prisma: PrismaService) {} listSeatClasses() { - return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } }); + return this.prisma.seatClass.findMany({ + where: { isActive: true }, + orderBy: { createdAt: 'asc' }, + }); } async getSeatClass(id: string) { const sc = await this.prisma.seatClass.findUnique({ where: { id } }); if (!sc) throw new NotFoundException('SeatClass not found'); + if (!sc.isActive) throw new NotFoundException('SeatClass is not active'); return sc; } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 690b42a43..a8d5d724c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -184,6 +184,27 @@ This makes it clear which segment of the route each seat is held for, enabling s return this.service.unblockSeat(seatId); } + // ── Maintenance ─────────────────────────────────────────────────────────── + @Post(":seatId/maintenance") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Set seat status to Under Maintenance" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat set to under maintenance" }) + setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) { + return this.service.setMaintenance(seatId, body.reason); + } + + @Delete(":seatId/maintenance") + @UseGuards(IamGuard) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Clear seat maintenance status" }) + @ApiParam({ name: "seatId", description: "Seat UUID" }) + @ApiResponse({ status: 200, description: "Seat cleared from maintenance" }) + clearMaintenance(@Param("seatId") seatId: string) { + return this.service.clearMaintenance(seatId); + } + // ── Remove Seat ──────────────────────────────────────────────────────────── @Patch(":seatId/remove") @UseGuards(IamGuard) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index fbb8cc552..1562a600b 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -742,6 +742,23 @@ export class SeatsService { return { unblocked: true, seatId }; } + async setMaintenance(seatId: string, reason: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance'); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } }); + await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } }); + return { maintenance: true, seatId, reason }; + } + + async clearMaintenance(seatId: string) { + const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); + if (!seat) throw new NotFoundException('Seat not found'); + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId } }); + return { maintenance: false, seatId }; + } + async removeSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 2eef0302e..16b486bbe 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -146,6 +146,96 @@ export class SegmentsService { return true; } + /** + * Batch availability check for multiple seats on a single schedule. + * Replaces N×isSeatFreeForLeg calls with 2 queries total. + * Returns a Set of seat IDs that are free for [reqFrom, reqTo). + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + ): Promise> { + if (seatIds.length === 0) return new Set(); + + const seqOf = (stationId: string) => + stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; + + const seatIdSet = new Set(seatIds); + const now = new Date(); + + const [allHolds, bookedLegs] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: now } }, + select: { seatIds: true, createdBy: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true }, + }), + ]); + + // Determine which seats are blocked by active holds + const holdBlockedSeats = new Set(); + for (const hold of allHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy as string); + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + + for (const sid of hold.seatIds) { + if (!seatIdSet.has(sid)) continue; + // Conservative block if leg can't be resolved; otherwise check overlap + if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) { + holdBlockedSeats.add(sid); + } + } + } + + // Build full journey ranges per seat (group multi-leg journeys) + const journeyRangesBySeat = new Map>(); + for (const leg of bookedLegs) { + if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; + const depSeq = seqOf(leg.departureStationId); + const arrSeq = seqOf(leg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + + let rangeMap = journeyRangesBySeat.get(leg.seatId); + if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); } + + const existing = rangeMap.get(leg.journeyId); + rangeMap.set(leg.journeyId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + const freeSeats = new Set(); + for (const seatId of seatIds) { + if (holdBlockedSeats.has(seatId)) continue; + let blocked = false; + const rangeMap = journeyRangesBySeat.get(seatId); + if (rangeMap) { + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { blocked = true; break; } + } + } + if (!blocked) freeSeats.add(seatId); + } + + return freeSeats; + } + /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts b/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts index f67808385..57b5a10cf 100644 --- a/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/trip-progress.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { EnhancedSeatsService } from './enhanced-seats.service'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; @@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; @Injectable() export class TripProgressService { + private readonly logger = new Logger(TripProgressService.name); constructor( private prisma: PrismaService, private enhancedSeatsService: EnhancedSeatsService, @@ -154,10 +155,10 @@ export class TripProgressService { try { const result = await this.enhancedSeatsService.expireHolds(); if (result.expiredHolds > 0) { - console.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`); + this.logger.log(`Expired ${result.expiredHolds} holds, released ${result.releasedSeats.length} seats`); } } catch (error) { - console.error('Error expiring holds:', error); + this.logger.error('Error expiring holds:', error); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 736e8cd58..9e9fc824d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -33,8 +33,11 @@ export class StationsService { where.countryCode = filters.country; } + // Default to operational stations only; allow explicit override (e.g. back-office) if (filters.operational !== undefined && filters.operational !== '') { where.isOperational = filters.operational === 'true'; + } else { + where.isOperational = true; } return this.prisma.station.findMany({ @@ -46,6 +49,7 @@ export class StationsService { async findOne(id: string) { const s = await this.prisma.station.findUnique({ where: { id } }); if (!s) throw new NotFoundException('Station not found'); + if (!s.isOperational) throw new NotFoundException('Station is not operational'); return s; } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 3c07d8c40..797adbb55 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -99,18 +99,11 @@ export class TicketsService { let guestEmail = null; const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName); - // DEBUG: Log to see what we're getting - this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`); - if (matchingProfile) { - this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`); - } - if (matchingProfile?.notes) { try { const notesData = JSON.parse(matchingProfile.notes); guestPhone = notesData.phone || null; guestEmail = notesData.email || null; - this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`); } catch (err) { this.logger.error(`Failed to parse notes JSON: ${err}`); } @@ -120,13 +113,11 @@ export class TicketsService { if (!guestPhone) guestPhone = t.booking?.contactPhone; if (!guestEmail) guestEmail = t.booking?.contactEmail; - this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`); const passengerInfo = iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; - this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`); return { id: t.id, @@ -596,8 +587,7 @@ export class TicketsService { } private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) { - // TODO: Implement notification logic - console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`); + this.logger.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`); } async getValidationLogs(ticketId: string) { @@ -687,4 +677,4 @@ export class TicketsService { if (!ticket) throw new NotFoundException('Ticket not found'); return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } }); } -} \ No newline at end of file +} diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 8e5e575e0..26108a452 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -16,7 +16,7 @@ export class StartVerificationDto { enum: ['WEB', 'MOBILE'], default: 'WEB', description: - 'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', }) @IsOptional() @IsIn(['WEB', 'MOBILE']) diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts index ede9aa9e8..0a821f235 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts @@ -35,6 +35,7 @@ function buildConfig(overrides?: Partial): FaydaConfig { tokenEndpoint: 'https://esignet.test/token', userInfoEndpoint: 'https://esignet.test/userinfo', redirectUri: 'http://localhost:4000/fayda/verification/complete', + webRedirectUri: 'http://localhost:5174/fayda/verification/complete', privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope: 'openid profile email', acrValues: 'mosip:idp:acr:generated-code', @@ -101,13 +102,14 @@ describe('VerifaydaService (OIDC, client-callback)', () => { expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize'); expect(parsed.searchParams.get('client_id')).toBe('edr-test-client'); expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + // Default platform is WEB → webRedirectUri. expect(parsed.searchParams.get('redirect_uri')).toBe( - 'http://localhost:4000/fayda/verification/complete', + 'http://localhost:5174/fayda/verification/complete', ); expect(parsed.searchParams.get('state')).toBe(created.state); }); - it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => { + it('sends the MOBILE redirect_uri (base redirectUri) for MOBILE sessions', async () => { prisma.faydaVerificationSession.create.mockResolvedValue({}); const url = await service.startVerification({ @@ -122,6 +124,19 @@ describe('VerifaydaService (OIDC, client-callback)', () => { ); }); + it('sends the WEB redirect_uri (webRedirectUri) for WEB sessions', async () => { + prisma.faydaVerificationSession.create.mockResolvedValue({}); + + const url = await service.startVerification({ + purpose: 'VERIFY', + platform: 'WEB', + }); + + expect(new URL(url).searchParams.get('redirect_uri')).toBe( + 'http://localhost:5174/fayda/verification/complete', + ); + }); + it('throws ServiceUnavailable when fayda integration is disabled', async () => { const disabledService = new VerifaydaService( buildConfigService(buildConfig({ enabled: false })), diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index e51bf1846..99b1d1d7c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -161,7 +161,18 @@ export class VerifaydaService { `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, ); - return this.buildAuthorizationUrl({ state, codeChallenge }); + return this.buildAuthorizationUrl({ + state, + codeChallenge, + redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + }); + } + + /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + private redirectUriForPlatform(platform?: FaydaPlatform): string { + return platform === 'MOBILE' + ? this.faydaConfig.redirectUri + : this.faydaConfig.webRedirectUri; } @@ -213,6 +224,7 @@ export class VerifaydaService { const tokens = await this.exchangeCodeForTokens( query.code, session.codeVerifier, + this.redirectUriForPlatform(session.platform as FaydaPlatform), ); const userInfo = await this.fetchUserInfo(tokens.access_token); const normalized = this.normalizeUserInfo(userInfo); @@ -307,11 +319,12 @@ export class VerifaydaService { private buildAuthorizationUrl(args: { state: string; codeChallenge: string; + redirectUri: string; }): string { const params = new URLSearchParams({ client_id: this.faydaConfig.clientId, response_type: 'code', - redirect_uri: this.faydaConfig.redirectUri, + redirect_uri: args.redirectUri, scope: this.faydaConfig.scope, state: args.state, code_challenge: args.codeChallenge, @@ -344,6 +357,7 @@ export class VerifaydaService { private async exchangeCodeForTokens( code: string, codeVerifier: string, + redirectUri: string, ): Promise { const clientAssertion = await generateClientAssertion({ clientId: this.faydaConfig.clientId, @@ -354,7 +368,7 @@ export class VerifaydaService { const body = new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: this.faydaConfig.redirectUri, + redirect_uri: redirectUri, client_id: this.faydaConfig.clientId, client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', diff --git a/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts index 1bade05ae..5cc4e8878 100644 --- a/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts +++ b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts @@ -42,6 +42,7 @@ export class EdrPassengerOrgSeeder { await this.ensureRoles(manager, EDR_PASSENGER_ROLES); await this.ensureRolePermissions(manager, EDR_PASSENGER_ROLES); await this.ensureSuperAdminPermissions(manager); + await this.ensurePositions(manager, organization.id); }); this.logger.log(`Ensured EDR passenger organization seed for '${EDR_ORG_KEY}'`); @@ -122,21 +123,51 @@ export class EdrPassengerOrgSeeder { const roleByKey = new Map(roles.map((r) => [r.key, r])); const permByKey = new Map(permissions.map((p) => [p.key, p])); - const links = seedRoles.flatMap((seedRole) => { + let totalUpserted = 0; + let totalPruned = 0; + + for (const seedRole of seedRoles) { const role = roleByKey.get(seedRole.key); if (!role) throw new Error(`missing_role:${seedRole.key}`); - return seedRole.permissionKeys.map((key) => { - const perm = permByKey.get(key); - if (!perm) throw new Error(`missing_permission:${key}`); - return { roleId: role.id, permissionId: perm.id }; - }); - }); + const desiredPermissionIds = new Set( + seedRole.permissionKeys.map((key) => { + const perm = permByKey.get(key); + if (!perm) throw new Error(`missing_permission:${key}`); + return perm.id as string; + }), + ); - await manager.getRepository(RolePermission).upsert(links, { - conflictPaths: { roleId: true, permissionId: true }, - }); - this.logger.log(`Ensured ${links.length} passenger role-permission links`); + // Remove links that are no longer in this role's preset + const existing = await manager.getRepository(RolePermission).find({ + where: { roleId: role.id as string }, + select: { permissionId: true }, + }); + const toRemove = existing + .map((rp) => rp.permissionId as string) + .filter((permId) => !desiredPermissionIds.has(permId)); + + if (toRemove.length > 0) { + await manager.getRepository(RolePermission).delete( + toRemove.map((permissionId) => ({ roleId: role.id as string, permissionId })), + ); + totalPruned += toRemove.length; + } + + // Upsert the full desired set + const links = [...desiredPermissionIds].map((permissionId) => ({ + roleId: role.id as string, + permissionId, + })); + await manager.getRepository(RolePermission).upsert(links, { + conflictPaths: { roleId: true, permissionId: true }, + }); + totalUpserted += links.length; + } + + this.logger.log( + `Synced passenger role-permission links: ${totalUpserted} upserted, ${totalPruned} pruned`, + ); } private async ensureSuperAdminPermissions(manager: EntityManager) { @@ -163,4 +194,34 @@ export class EdrPassengerOrgSeeder { ); this.logger.log(`Ensured ${permissions.length} passenger permissions on super_admin`); } + + private async ensurePositions(manager: EntityManager, organizationId: string) { + const positions = [ + { key: 'edr_passenger_director', name: { en: 'Director', am: 'ዳይሬክተር' }, rank: 1 }, + { key: 'edr_passenger_finance_manager', name: { en: 'Finance Manager', am: 'የፋይናንስ ሥራ አስኪያጅ' }, rank: 2 }, + { key: 'edr_passenger_finance_officer', name: { en: 'Finance Officer', am: 'የፋይናንስ ኦፊሰር' }, rank: 3 }, + { key: 'edr_passenger_team_leader', name: { en: 'Team Leader', am: 'ቡድን መሪ' }, rank: 4 }, + { key: 'edr_passenger_station_master', name: { en: 'Station Master', am: 'ጣቢያ ሃላፊ' }, rank: 5 }, + { key: 'edr_passenger_station_supervisor', name: { en: 'Station Supervisor', am: 'ጣቢያ ተቆጣጣሪ' }, rank: 6 }, + { key: 'edr_passenger_ticket_officer', name: { en: 'Passenger Ticket Officer', am: 'የተሳፋሪ ቲኬት ኦፊሰር' }, rank: 7 }, + { key: 'edr_passenger_operational_staff', name: { en: 'Operational Staff', am: 'ስራ ሰራተኛ' }, rank: 8 }, + ]; + + let created = 0; + for (const pos of positions) { + const existing = await manager.query>( + `SELECT id FROM iam.positions WHERE key = $1 AND organization_id = $2 LIMIT 1`, + [pos.key, organizationId], + ); + if (existing.length === 0) { + await manager.query( + `INSERT INTO iam.positions (id, name, key, rank, organization_id, unit_id, position_type_id, created_at, updated_at) + VALUES (gen_random_uuid(), $1::jsonb, $2, $3, $4::uuid, NULL, NULL, NOW(), NOW())`, + [JSON.stringify(pos.name), pos.key, pos.rank, organizationId], + ); + created++; + } + } + this.logger.log(`Ensured ${positions.length} passenger positions (${created} newly created)`); + } } diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts index cd178139a..578cf66ff 100644 --- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -30,15 +30,25 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [ permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeAdmin], }, { - key: 'edr_passenger_backoffice_staff', - name: { en: 'EDR Passenger Backoffice Staff' }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeStaff], + key: 'edr_passenger_station_master', + name: { en: 'EDR Passenger Station Master' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.stationMaster], + }, + { + key: 'edr_passenger_ticket_officer', + name: { en: 'EDR Passenger Ticket Officer' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.ticketOfficer], }, { key: 'edr_passenger_agent', name: { en: 'EDR Passenger Agent' }, permissionKeys: [...ROLE_PERMISSION_PRESETS.agent], }, + { + key: 'edr_passenger_backoffice_staff', + name: { en: 'EDR Passenger Backoffice Staff' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeStaff], + }, { key: 'edr_passenger_finance', name: { en: 'EDR Passenger Finance' }, diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index d6a804b3a..bab7787b9 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -87,29 +87,42 @@ export const PASSENGER_PERMS = { export const ROLE_PERMISSION_PRESETS = { backofficeAdmin: [...PASSENGER_PERMISSION_KEYS], - backofficeStaff: [ + stationMaster: [ PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.bookings.manage, - PASSENGER_PERMS.bookings.cancel, - PASSENGER_PERMS.passengers.view, - PASSENGER_PERMS.passengers.manage, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, - PASSENGER_PERMS.payments.viewAll, - PASSENGER_PERMS.reports.view, - PASSENGER_PERMS.dashboard.view, - PASSENGER_PERMS.notifications.send, + PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.agents.view, - PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.audit.view, + PASSENGER_PERMS.notifications.send, + PASSENGER_PERMS.dashboard.view, + ], + + ticketOfficer: [ + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.dashboard.view, ], agent: [ PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.bookings.cancel, PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.payments.refund, + PASSENGER_PERMS.dashboard.view, + ], + + backofficeStaff: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.dashboard.view, ], finance: [ diff --git a/apps/edr-passenger-web/backoffice/.env.example b/apps/edr-passenger-web/backoffice/.env.example index 5263b3a36..058069154 100644 --- a/apps/edr-passenger-web/backoffice/.env.example +++ b/apps/edr-passenger-web/backoffice/.env.example @@ -5,5 +5,5 @@ NEXT_PUBLIC_API_URL=https://your-api-domain.com NEXT_PUBLIC_IAM_ENABLED=false NEXT_PUBLIC_IAM_API_URL=https://iam.tria-plc.com/api -# GitHub Packages Token -GITHUB_PACKAGE_TOKEN=$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 +# GitHub Packages Token (required to install @tria-plc/* private packages) +GITHUB_PACKAGE_TOKEN= diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 0153887ae..4478fce74 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -131,9 +131,38 @@ export default function AuditLogsPage() { return (
-
-

Audit Logs

-

Track all system activities and changes

+
+
+

Audit Logs

+

Track all system activities and changes

+
+ { + const items = data?.items || []; + if (!items.length) return; + const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address']; + const rows = items.map((l: any) => [ + formatDateTime(l.createdAt), + l.action, + l.entityType, + l.entityId || '', + l.iamUserId || l.userId || '', + l.ipAddress || '', + ]); + const csv = [headers, ...rows].map(r => r.map((v: string) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }} + > + Export CSV +
{/* Stats Cards */} diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index a808d44f1..db5f362dc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -14,32 +14,142 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const videoRef = useRef(null); const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); + const [isInitializing, setIsInitializing] = useState(false); const [stream, setStream] = useState(null); const [cameraError, setCameraError] = useState(null); const scanIntervalRef = useRef(null); const startCamera = async () => { try { + setIsInitializing(true); setCameraError(null); - const mediaStream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: 'environment', // Use back camera - width: { ideal: 1280 }, - height: { ideal: 720 } + + // Check if mediaDevices is supported + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.'; + setCameraError(errorMsg); + onError(errorMsg); + setIsInitializing(false); + return; + } + + // First, stop any existing stream + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setStream(null); + } + + // Request camera access with simpler fallback + let mediaStream: MediaStream | null = null; + + try { + // Try with environment (back) camera first + mediaStream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 } + }, + audio: false + }); + } catch { + // Fallback to any available camera with simple constraints + try { + mediaStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false + }); + } catch (fallbackErr) { + throw fallbackErr; } + } + + if (!mediaStream) { + throw new Error('Failed to get media stream'); + } + + if (!videoRef.current) { + throw new Error('Video element not found'); + } + + const video = videoRef.current; + video.srcObject = mediaStream; + + // Wait for video to be ready with proper event handling + await new Promise((resolve, reject) => { + let resolved = false; + + const cleanup = () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('loadeddata', onLoadedData); + video.removeEventListener('canplay', onCanPlay); + video.removeEventListener('error', onVideoError); + }; + + const finishResolve = () => { + if (!resolved) { + resolved = true; + cleanup(); + resolve(); + } + }; + + const onLoadedMetadata = () => finishResolve(); + const onLoadedData = () => finishResolve(); + const onCanPlay = () => finishResolve(); + + const onVideoError = (_e: Event) => { + cleanup(); + reject(new Error('Video failed to load')); + }; + + // Add multiple event listeners for better compatibility + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('loadeddata', onLoadedData); + video.addEventListener('canplay', onCanPlay); + video.addEventListener('error', onVideoError); + + setTimeout(() => finishResolve(), 2000); }); - if (videoRef.current) { - videoRef.current.srcObject = mediaStream; - await videoRef.current.play(); - setStream(mediaStream); - setIsScanning(true); + try { + await video.play(); + } catch { + await new Promise(resolve => setTimeout(resolve, 100)); + try { await video.play(); } catch { /* continue */ } } + + // Set state to show video + setStream(mediaStream); + setIsScanning(true); + setIsInitializing(false); + } catch (error: any) { - const errorMsg = 'Camera access denied. Please enable camera permissions in browser settings.'; + + let errorMsg = 'Camera access failed. Please check permissions and try again.'; + + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + errorMsg = 'Camera permission denied. Please allow camera access in your browser settings and try again.'; + } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { + errorMsg = 'No camera found. Please connect a camera and try again.'; + } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { + errorMsg = 'Camera is already in use by another application. Please close other apps using the camera.'; + } else if (error.name === 'OverconstrainedError') { + errorMsg = 'Camera does not meet the requirements. Please try a different camera.'; + } else if (error.name === 'SecurityError') { + errorMsg = 'Camera access blocked due to security settings. Please use HTTPS or check your browser security settings.'; + } + setCameraError(errorMsg); onError(errorMsg); - console.error('Camera error:', error); + + // Clean up on error + if (stream) { + stream.getTracks().forEach(track => track.stop()); + setStream(null); + } + setIsScanning(false); + setIsInitializing(false); } }; @@ -75,21 +185,17 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); try { - // Try to use jsqr if available const jsQR = (window as any).jsQR; if (jsQR) { const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert', }); - if (code) { onScan(code.data); stopCamera(); } } - } catch (err) { - console.error('QR scan error:', err); - } + } catch { /* ignore scan errors */ } } }, [isScanning, onScan, stopCamera]); @@ -120,7 +226,43 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro return (
- {!isScanning ? ( + {/* Video viewer - always rendered, visibility controlled by display style */} +
+
+
+ + +
+ + {/* Start button and loading state */} + {!isScanning && !isInitializing && (
)}
- ) : ( + )} + + {/* Loading state */} + {isInitializing && (
-
-
{/* Quick Stats */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index f8ee29091..5860ea748 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; -type Tab = 'types' | 'coaches'; +type Tab = 'types' | 'coaches' | 'utilization'; const getBedLabel = (bedPosition: string | null): string => { if (bedPosition === 'upper') return 'U'; @@ -144,11 +144,10 @@ export default function CoachesPage() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); - const [showPreviewModal, setShowPreviewModal] = useState(false); - const [seatMapPreview, setSeatMapPreview] = useState(null); const [editingItem, setEditingItem] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); + const queryClient = useQueryClient(); // Coach Types Queries @@ -163,6 +162,12 @@ export default function CoachesPage() { queryFn: () => fleetApi.getCoaches({}), }); + const { data: utilizationData, isLoading: utilizationLoading } = useQuery({ + queryKey: ['coach-utilization'], + queryFn: () => apiClient.get('/fleet/coaches/utilization'), + enabled: activeTab === 'utilization', + }); + // Coach Type Mutations const createCoachTypeMutation = useMutation({ mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data), @@ -215,14 +220,6 @@ export default function CoachesPage() { }, }); - const generateSeatMapMutation = useMutation({ - mutationFn: fleetApi.generateSeatMap, - onSuccess: (data) => { - setSeatMapPreview(data); - setShowPreviewModal(true); - }, - }); - const handleCoachTypeSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -269,27 +266,6 @@ export default function CoachesPage() { } }; - const handlePreviewSeatMap = async () => { - const form = document.querySelector('form') as HTMLFormElement; - const formData = new FormData(form); - const bedCategory = formData.get('bedCategory') as string; - const capacity = parseInt(formData.get('capacity') as string); - - if (!bedCategory || !capacity) { - alert('Please select a bed category and enter capacity to preview seat map'); - return; - } - - const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6; - const roomsPerCoach = Math.ceil(capacity / bedsPerRoom); - - await generateSeatMapMutation.mutateAsync({ - coachCount: 1, - roomsPerCoach, - roomType: bedCategory, - }); - }; - const handleDelete = (item: any, isCoachType: boolean) => { setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } }); }; @@ -547,6 +523,16 @@ export default function CoachesPage() { > Coaches +
{/* Coach Types Tab */} @@ -594,6 +580,44 @@ export default function CoachesPage() { />
)} + + {/* Utilization Tab */} + {activeTab === 'utilization' && (() => { + const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; + return ( +
+ {r.sequence} }, + { key: 'number', label: 'Coach', render: (r: any) => {r.number} }, + { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, + { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, + { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, + { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, + { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, + { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, + { + key: 'utilizationRate', label: 'Utilization', + render: (r: any) => ( +
+
+
+
+ {r.utilizationRate}% +
+ ), + }, + { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, + { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, + ]} + data={rows} + actions={[]} + loading={utilizationLoading} + emptyMessage="No coach utilization data available" + /> +
+ ); + })()}
{/* Delete Confirmation */} @@ -725,23 +749,25 @@ export default function CoachesPage() { />
- {/* Conditionally show bed fields only for Economy and Regular coach types */} {(() => { const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId)); - const isEconomyOrRegular = selectedCoachType && - (selectedCoachType.name?.toLowerCase().includes('economy') || - selectedCoachType.name?.toLowerCase().includes('regular') || - selectedCoachType.type?.toLowerCase().includes('economy') || - selectedCoachType.type?.toLowerCase().includes('regular')); - - return isEconomyOrRegular ? ( + const isBedType = selectedCoachType && + (selectedCoachType.name?.toLowerCase().includes('bed') || + selectedCoachType.name?.toLowerCase().includes('sleeper') || + selectedCoachType.type?.toLowerCase().includes('sleeper')); + + const derivedBedCategory = editingItem?.isCoach && selectedCoachType + ? (selectedCoachType.name?.toLowerCase().includes('vip') ? 'VIP_BED' : 'ECONOMY_BED') + : (editingItem?.bedCategory || ''); + + return isBedType ? ( <>
setFilters({ ...filters, scheduleId: e.target.value })}> + + {schedules.map((s: any) => ( + + ))} +
+ - {/* Configurations Table */} -
-
-

Fare Configurations

-

- Manage fare calculation configurations with custom rates, components, and age-based pricing -

-
- - -
- - {/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, config: null })} - onConfirm={confirmDelete} - title="Delete Configuration" - message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} - confirmText="Delete" - isDanger={true} - isLoading={deleteMutation.isPending} - warning="Active configurations cannot be deleted. Deactivate first if needed." + onClose={() => setDeleteConfirm({ isOpen: false, rule: null })} + onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)} + title="Delete Fare Rule" + message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`} + confirmText="Delete" isDanger isLoading={deleteMutation.isPending} + error={deleteConfirm.error} /> - {/* Test Modal */} - {showTestModal && selectedConfig && ( - { - setShowTestModal(false); - setSelectedConfig(null); - }} - /> - )} - - {/* Create/Edit Modal */} - {showCreateModal && ( - setShowCreateModal(false)} - onSuccess={() => { - setShowCreateModal(false); - queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); - }} - /> - )} + { setShowModal(false); setEditingRule(null); }} + title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg"> +
+ {formError && ( +
{formError}
+ )} +
+
+ + +
+
+ + +
+
+ + +

Leave blank to apply to all passengers

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ { setShowModal(false); setEditingRule(null); }}>Cancel + + {editingRule ? 'Update' : 'Create'} Fare Rule + +
+
+
); } - -// Test Modal Component -function FareTestModal({ - configuration, - isOpen, - onClose -}: { - configuration: FareConfiguration; - isOpen: boolean; - onClose: () => void; -}) { - const [testData, setTestData] = useState({ - distanceKm: 100, - nationality: 'Ethiopian', - coachType: 'REGULAR_SEAT', - bedPosition: '', - adultCount: 2, - childCount: 1, - }); - - const testMutation = useMutation({ - mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData), - }); - - const handleTest = () => { - testMutation.mutate(); - }; - - return ( - -
-
-
- - setTestData({ ...testData, distanceKm: +e.target.value })} - /> -
-
- - -
-
- - -
- {(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && ( -
- - -
- )} -
- - setTestData({ ...testData, adultCount: +e.target.value })} - /> -
-
- - setTestData({ ...testData, childCount: +e.target.value })} - /> -
-
- - - Calculate Fare - - - {testMutation.data && ( -
-

Calculation Result

-
-
- Base Fare: - {(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB -
-
- Components: - {(testMutation.data.componentsTotal / 100).toFixed(2)} ETB -
-
- Total: - {(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB -
-
- - {testMutation.data.breakdown && ( -
-
Calculation Breakdown:
-
- {testMutation.data.breakdown.map((step: any, index: number) => ( -
- {step.description} - {(step.runningTotal / 100).toFixed(2)} ETB -
- ))} -
-
- )} -
- )} - - {testMutation.error && ( -
- {(testMutation.error as any)?.response?.data?.message || 'Test failed'} -
- )} -
-
- ); -} - -// Create Configuration Form Modal -function ConfigurationFormModal({ - isOpen, - onClose, - onSuccess -}: { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -}) { - return ( - -
-

Configuration Form

-

- This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing. -

- - Close for Now - -
-
- ); -} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index b2c90d275..f104db3da 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -5,9 +5,10 @@ import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/lib/theme-store'; import { - Eye, EyeOff, Sun, Moon, ArrowRight, Loader2, - TicketCheck, Users, TrendingUp, ShieldCheck, + Eye, EyeOff, Sun, Moon, ArrowRight, ArrowLeft, Loader2, + TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck, } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; const EDR_GREEN = 'rgb(20, 113, 76)'; @@ -28,6 +29,13 @@ export default function LoginPage() { const [emailFocused, setEmailFocused] = useState(false); const [passwordFocused, setPasswordFocused] = useState(false); + const [view, setView] = useState<'login' | 'forgot'>('login'); + const [forgotEmail, setForgotEmail] = useState(''); + const [forgotLoading, setForgotLoading] = useState(false); + const [forgotError, setForgotError] = useState(''); + const [forgotSent, setForgotSent] = useState(false); + const [forgotFocused, setForgotFocused] = useState(false); + const router = useRouter(); const { login } = useAuthStore(); const { isDark, toggleTheme } = useTheme(); @@ -53,6 +61,31 @@ export default function LoginPage() { } }; + const handleForgotSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setForgotLoading(true); + setForgotError(''); + try { + await iamAuthApi.forgotPassword(forgotEmail); + setForgotSent(true); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setForgotError( + msg === 'user_not_found' + ? 'No account found with that email address.' + : msg || 'Failed to send the reset link. Please try again.' + ); + } finally { + setForgotLoading(false); + } + }; + + const backToLogin = () => { + setView('login'); + setForgotError(''); + setForgotSent(false); + }; + if (!isMounted) return null; return ( @@ -94,6 +127,8 @@ export default function LoginPage() { {/* Form area */}
+ {view === 'login' ? ( + <> {/* Heading */}
@@ -171,6 +206,15 @@ export default function LoginPage() { {showPassword ? : }
+
+ +
{/* Submit */} @@ -206,6 +250,112 @@ export default function LoginPage() {

+ + ) : ( + <> + + {/* Heading */} +
+

+ Reset your password +

+

+ Enter your email address and we'll send a reset link to the phone number on your account. +

+
+ + {forgotSent ? ( +
+
+ +

+ A password reset link has been sent via SMS. Open it to set a new password — the link expires in 30 minutes. +

+
+ +
+ ) : ( + <> + {/* Error */} + {forgotError && (
+
+
+ ! +
+

{forgotError}

+
+ )} + +
+ {/* Email field */} +
+ +
+ { setForgotEmail(e.target.value); setForgotError(''); }} + onFocus={() => setForgotFocused(true)} + onBlur={() => setForgotFocused(false)} + className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="name@edr.com" + required + autoComplete="email" + /> +
+
+ + {/* Submit */} + +
+ + + + )} + + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index 29f36dd0a..f7a0c1887 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -32,7 +32,6 @@ export default function OperationalReportsPage() { refetch(); setShowGenerateModal(false); } catch (error) { - console.error('Error generating report:', error); } }; diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx index ae3b20837..3e8b8c849 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -64,7 +64,6 @@ export default function PaymentMethodsPage() { setTimeout(() => setSuccessMessage(''), 3000); }, onError: (error) => { - console.error('Update failed:', error); setSuccessMessage('Failed to update payment method'); setTimeout(() => setSuccessMessage(''), 3000); }, @@ -131,8 +130,6 @@ export default function PaymentMethodsPage() { processingTime: formData.processingTime, }; - console.log('Submitting data:', submitData); - if (selectedMethod) { updateMutation.mutate({ id: selectedMethod.id, ...submitData }); } else { diff --git a/apps/edr-passenger-web/backoffice/src/app/providers.tsx b/apps/edr-passenger-web/backoffice/src/app/providers.tsx index 663a52e05..5130e8ec4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/providers.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/providers.tsx @@ -1,15 +1,15 @@ -'use client'; +"use client"; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState, useEffect } from 'react'; -import { useTheme } from '@/lib/theme-store'; -import { useAuthStore } from '@/lib/auth-store'; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { useTheme } from "@/lib/theme-store"; +import { useAuthStore } from "@/lib/auth-store"; function ThemeProvider({ children }: { children: React.ReactNode }) { const { isDark, setTheme } = useTheme(); useEffect(() => { - document.documentElement.classList.toggle('dark', isDark); + document.documentElement.classList.toggle("dark", isDark); }, [isDark]); return <>{children}; @@ -26,14 +26,16 @@ function AuthProvider({ children }: { children: React.ReactNode }) { } export default function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient({ - defaultOptions: { - queries: { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - }, - }, - })); + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }), + ); return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/reset-password/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reset-password/page.tsx new file mode 100644 index 000000000..62e81322c --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reset-password/page.tsx @@ -0,0 +1,228 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const verificationCode = searchParams.get('verificationCode') || ''; + const linkValid = Boolean(email && userId && verificationCode); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [newFocused, setNewFocused] = useState(false); + const [confirmFocused, setConfirmFocused] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPassword.length < 6) { + setError('Password must be at least 6 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + await iamAuthApi.resetPassword({ userId, email, verificationCode, newPassword, confirmPassword }); + setSuccess(true); + setTimeout(() => router.push('/login'), 2000); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + {/* Logo */} +
+
+ + + + + + +
+
+
ETHIO-DJIBOUTI
+
Railway
+
+
+ + {!linkValid ? ( +
+

+ Invalid reset link +

+

+ This password reset link is invalid or incomplete. Request a new one from the sign-in page. +

+ + + Back to sign in + +
+ ) : success ? ( +
+
+ +

+ Password reset successfully. Redirecting to sign in… +

+
+ + Go to sign in + + +
+ ) : ( + <> + {/* Heading */} +
+

+ Set a new password +

+

+ Choose a new password for {email}. +

+
+ + {/* Error */} + {error && ( +
+
+ ! +
+

{error}

+
+ )} + +
+ {/* New password */} +
+ +
+ { setNewPassword(e.target.value); setError(''); }} + onFocus={() => setNewFocused(true)} + onBlur={() => setNewFocused(false)} + className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="••••••••••" + required + minLength={6} + autoComplete="new-password" + /> + +
+
+ + {/* Confirm password */} +
+ +
+ { setConfirmPassword(e.target.value); setError(''); }} + onFocus={() => setConfirmFocused(true)} + onBlur={() => setConfirmFocused(false)} + className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="••••••••••" + required + minLength={6} + autoComplete="new-password" + /> +
+
+ + {/* Submit */} + +
+ + + + Back to sign in + + + )} +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 18006fc18..4ed841384 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -33,7 +33,6 @@ export default function RoutesPage() { queryKey: ['routes'], queryFn: async () => { const result = await routesApi.getAll(); - console.log('Routes query result:', result); return result; }, }); @@ -71,7 +70,7 @@ export default function RoutesPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); - + if (!originStationId || !destinationStationId) { alert('Please select origin and destination stations'); return; @@ -112,9 +111,7 @@ export default function RoutesPage() { effectiveUntil: formData.get('effectiveUntil') as string || undefined, stops: stopsArray, }; - - console.log('Submitting route data:', JSON.stringify(routeData, null, 2)); - + if (editingRoute) { await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData }); } else { @@ -189,8 +186,8 @@ export default function RoutesPage() { { key: 'code', label: 'Route Code', sortable: true }, { key: 'name', label: 'Route Name', sortable: true }, { key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' }, - { - key: 'active', + { + key: 'active', label: 'Status', render: (route: any) => ( @@ -220,15 +217,20 @@ export default function RoutesPage() { if (routeStops.length >= 2) { setOriginStationId(routeStops[0].stationId); setDestinationStationId(routeStops[routeStops.length - 1].stationId); - - // Last stop's distanceKm is already cumulative from origin - setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0); - const middleStops = routeStops.slice(1, -1).map((stop: any) => ({ + // Last stop's distanceKm is segment distance from previous stop, so accumulate + let cumulative = 0; + const allStops = routeStops.map((stop: any) => { + cumulative += stop.distanceKm || 0; + return { ...stop, _cumulative: cumulative }; + }); + setDestinationDistance(allStops[allStops.length - 1]._cumulative); + + const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({ stationId: stop.stationId, sequence: stop.sequence, distanceKm: stop.distanceKm, - distanceFromOrigin: stop.distanceKm || 0, + distanceFromOrigin: allStops[idx + 1]._cumulative, })); setStops(middleStops); } @@ -392,7 +394,7 @@ export default function RoutesPage() { )} - +