mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
@@ -56,6 +57,7 @@ import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-inte
|
||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
@@ -79,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
// EventEmitterModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -144,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
@@ -173,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -199,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
// Government entities (with importer/exporter profiles) that government
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Freight billing — `invoices` + `invoice_lines` tables.
|
||||
*
|
||||
* Matches:
|
||||
* - billing/entities/invoice.entity.ts
|
||||
* - billing/entities/invoice-line.entity.ts
|
||||
*
|
||||
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
|
||||
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
|
||||
* column resolves to it without an explicit `enumName`.
|
||||
*/
|
||||
export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
name = "CreateInvoices1821000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.invoices_status_enum AS ENUM (
|
||||
'DRAFT',
|
||||
'PENDING',
|
||||
'PAID',
|
||||
'OVERDUE',
|
||||
'CANCELLED',
|
||||
'REFUNDED'
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.invoices (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_number varchar(64) NOT NULL,
|
||||
company_id uuid NOT NULL,
|
||||
company_profile_id uuid NOT NULL,
|
||||
total_amount numeric(14, 2) NOT NULL,
|
||||
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||
source varchar(255) NOT NULL,
|
||||
source_id varchar(255) NOT NULL,
|
||||
type varchar(255) NOT NULL,
|
||||
issued_at timestamptz,
|
||||
payment_id uuid,
|
||||
due_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_invoices PRIMARY KEY (id),
|
||||
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
|
||||
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
|
||||
REFERENCES freight.companies (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
|
||||
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
|
||||
REFERENCES freight.payments (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.invoice_lines (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL,
|
||||
charge_type varchar NOT NULL,
|
||||
description varchar(255),
|
||||
quantity numeric(12, 2) NOT NULL DEFAULT 1,
|
||||
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
amount numeric(14, 2) NOT NULL,
|
||||
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||
metadata jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
|
||||
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
|
||||
REFERENCES freight.invoices (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Government bookings now bill to a real seeded government company + an explicit
|
||||
* importer/exporter profile, instead of carrying a null company + free-text
|
||||
* institution. This migration:
|
||||
*
|
||||
* 1. Adds `companies.kind` (commercial | government).
|
||||
* 2. Seeds the Ethiopian government entities + their importer/exporter
|
||||
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
|
||||
* 3. Backfills every booking with a NULL company_id / company_profile_id so
|
||||
* the NOT NULL constraints below can be applied:
|
||||
* - NULL company_id → the default government company.
|
||||
* - NULL company_profile_id → the company's profile matching the booking
|
||||
* trade direction; else any profile of the company; else the default
|
||||
* government importer profile.
|
||||
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
|
||||
*/
|
||||
export class AddCompanyKindAndGovBookingLinks1821000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddCompanyKindAndGovBookingLinks1821000000003";
|
||||
|
||||
// Mirrors src/seed/data/gov-companies.data.ts
|
||||
private readonly govCompanies = [
|
||||
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
|
||||
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
|
||||
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
|
||||
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
|
||||
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
|
||||
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
|
||||
];
|
||||
|
||||
private get defaultCompanyId(): string {
|
||||
return this.govCompanies[0].id;
|
||||
}
|
||||
private get defaultImporterProfileId(): string {
|
||||
return this.govCompanies[0].im;
|
||||
}
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. kind column
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
|
||||
);
|
||||
|
||||
// 2. seed government companies + importer/exporter profiles (idempotent)
|
||||
for (const g of this.govCompanies) {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
|
||||
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
|
||||
ON CONFLICT ("id") DO NOTHING`,
|
||||
[g.id, g.name, g.tin, g.email, g.phone],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
|
||||
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
|
||||
ON CONFLICT ("id") DO NOTHING`,
|
||||
[g.im, g.id, g.imRef, g.ex, g.exRef],
|
||||
);
|
||||
}
|
||||
|
||||
// 3a. backfill NULL company_id → default government company
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
|
||||
[this.defaultCompanyId],
|
||||
);
|
||||
|
||||
// 3b. backfill NULL company_profile_id → profile matching trade direction
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" b
|
||||
SET "company_profile_id" = cp."id"
|
||||
FROM "freight"."company_profiles" cp
|
||||
WHERE b."company_profile_id" IS NULL
|
||||
AND cp."company_id" = b."company_id"
|
||||
AND cp."deleted_at" IS NULL
|
||||
AND cp."type" = CASE b."trade_direction"
|
||||
WHEN 'IMPORT' THEN 'importer'
|
||||
WHEN 'EXPORT' THEN 'exporter'
|
||||
ELSE NULL END`,
|
||||
);
|
||||
|
||||
// 3c. fallback → any profile of the booking's company
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" b
|
||||
SET "company_profile_id" = (
|
||||
SELECT cp."id" FROM "freight"."company_profiles" cp
|
||||
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
|
||||
ORDER BY cp."created_at" ASC LIMIT 1)
|
||||
WHERE b."company_profile_id" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "freight"."company_profiles" cp
|
||||
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
|
||||
);
|
||||
|
||||
// 3d. final fallback → default government importer profile
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
|
||||
[this.defaultImporterProfileId],
|
||||
);
|
||||
|
||||
// 4. enforce NOT NULL
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
|
||||
);
|
||||
// Seeded government rows are intentionally left in place.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Make the payment projection source-agnostic so any domain (not just bookings)
|
||||
* can own a payment intent.
|
||||
*
|
||||
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
|
||||
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
|
||||
* new domain no longer needs an enum migration to write its intents.
|
||||
* - adds `payments.reference_type varchar(40)` — the gateway reference type
|
||||
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
|
||||
* path can query the provider without hardcoding it.
|
||||
*
|
||||
* Matches payment/entities/payment.entity.ts.
|
||||
*/
|
||||
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
|
||||
name = "MakePaymentsTypeGeneric1821000000004";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
|
||||
);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
|
||||
);
|
||||
|
||||
// Restore the single-value enum. Any non-'booking' rows would block the cast;
|
||||
// collapse them first so the down migration is safe.
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
constructor(private readonly billingService: BillingService) { }
|
||||
|
||||
@Get("invoices")
|
||||
@ApiOperation({ summary: "List all invoices" })
|
||||
@@ -16,9 +16,9 @@ export class BillingController {
|
||||
return this.billingService.findAll();
|
||||
}
|
||||
|
||||
@Get("invoices/booking/:bookingId")
|
||||
@ApiOperation({ summary: "List invoices for a booking" })
|
||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
return this.billingService.findByBooking(bookingId);
|
||||
@Get("invoices/:id")
|
||||
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BillingController } from "./billing.controller";
|
||||
import { PortalBillingController } from "./portal-billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Invoice])],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||
forwardRef(() => PaymentModule),
|
||||
CompaniesModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
/**
|
||||
* Minimal in-memory EntityManager stand-in covering the methods
|
||||
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
|
||||
*/
|
||||
function makeManager(savedLines: unknown[]) {
|
||||
return {
|
||||
create: (_entity: unknown, data: Record<string, unknown>) => data,
|
||||
save: (data: Record<string, unknown>) => {
|
||||
const row = { id: data.id ?? "gen-1", ...data };
|
||||
if (data.invoiceId) savedLines.push(row);
|
||||
return Promise.resolve(row);
|
||||
},
|
||||
query: () => Promise.resolve([{ seq: 0 }]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "prepaid",
|
||||
companyId: "company-1",
|
||||
companyProfileId: "profile-1",
|
||||
currency: "ETB",
|
||||
lines: [
|
||||
{
|
||||
chargeType: "RAIL_FREIGHT",
|
||||
description: "Rail freight",
|
||||
quantity: 2,
|
||||
unitRate: 500,
|
||||
amount: 1000,
|
||||
},
|
||||
{
|
||||
chargeType: "HAZARD_SURCHARGE",
|
||||
description: "Hazard surcharge",
|
||||
quantity: 2,
|
||||
unitRate: 250,
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BillingService.generateInvoice", () => {
|
||||
let savedLines: unknown[];
|
||||
let manager: ReturnType<typeof makeManager>;
|
||||
let events: ReturnType<typeof makeEvents>;
|
||||
let dataSource: { transaction: jest.Mock; manager: unknown };
|
||||
let service: BillingService;
|
||||
|
||||
beforeEach(() => {
|
||||
savedLines = [];
|
||||
manager = makeManager(savedLines);
|
||||
events = makeEvents();
|
||||
dataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
service = new BillingService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a PENDING invoice with one line per input line", async () => {
|
||||
const invoice = await service.generateInvoice(generateInput());
|
||||
|
||||
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(invoice.companyId).toBe("company-1");
|
||||
expect(invoice.source).toBe("booking");
|
||||
expect(invoice.sourceId).toBe("booking-1");
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sums line amounts when no explicit totalAmount is given", async () => {
|
||||
const invoice = await service.generateInvoice(
|
||||
generateInput({ totalAmount: undefined }),
|
||||
);
|
||||
expect(invoice.totalAmount).toBe(1500);
|
||||
});
|
||||
|
||||
it("leaves issuedAt null for a DRAFT invoice", async () => {
|
||||
const invoice = await service.generateInvoice(
|
||||
generateInput({ status: Freight.InvoiceStatus.Draft }),
|
||||
);
|
||||
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
|
||||
expect(invoice.issuedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("enlists in a caller's transaction when a manager is passed", async () => {
|
||||
await service.generateInvoice(generateInput(), manager as never);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(savedLines).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.markInvoiceAsPaid", () => {
|
||||
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paymentId: "pay-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (no event) when the invoice is already paid", async () => {
|
||||
const paid = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
source: "booking",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(paid),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.settlePayable", () => {
|
||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||
const open = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(open),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
||||
expect(mg.update).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: "inv-1" },
|
||||
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
||||
const mg = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: mg } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
);
|
||||
|
||||
const settled = await service.settlePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"pay-1",
|
||||
mg as never,
|
||||
);
|
||||
|
||||
expect(settled).toBeNull();
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,542 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
|
||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||
export interface PayInvoiceOptions {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Draft,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
description?: string;
|
||||
/** Units this line bills for; defaults to 1. */
|
||||
quantity?: number;
|
||||
/** Price per unit; defaults to 0. */
|
||||
unitRate?: number;
|
||||
/** Line total; defaults to `quantity * unitRate`. */
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Everything needed to generate an invoice for any source. */
|
||||
export interface GenerateInvoiceInput {
|
||||
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
|
||||
source: Freight.InvoiceSource;
|
||||
/** Identifier of the source record (e.g. booking id). */
|
||||
sourceId: string;
|
||||
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
lines: InvoiceLineInput[];
|
||||
currency?: string;
|
||||
/** Explicit total; defaults to the sum of line amounts. */
|
||||
totalAmount?: number;
|
||||
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||
dueAt?: Date;
|
||||
dueInDays?: number;
|
||||
/**
|
||||
* Initial status. DRAFT leaves `issuedAt` null; any issued status
|
||||
* (default PENDING) stamps `issuedAt`.
|
||||
*/
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||
export interface InvoiceEventPayload {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
source: Freight.InvoiceSource;
|
||||
sourceId: string;
|
||||
type: string;
|
||||
companyId: string;
|
||||
companyProfileId: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: Freight.InvoiceStatus;
|
||||
paymentId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Invoice)
|
||||
private readonly invoicesRepository: Repository<Invoice>,
|
||||
) {}
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoices: InvoiceRepository,
|
||||
private readonly invoiceLines: InvoiceLineRepository,
|
||||
private readonly events: EventEmitter2,
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List every invoice (most recent first). */
|
||||
findAll(): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
|
||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||
}
|
||||
|
||||
/** List invoices for a given booking. */
|
||||
findByBooking(bookingId: string): Promise<Invoice[]> {
|
||||
return this.invoicesRepository.find({
|
||||
where: { bookingId },
|
||||
/** Invoice header plus its line items. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.invoices.findById(id);
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||
|
||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||
async resolveCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||
return this.invoices.findAll({
|
||||
where: { companyId },
|
||||
relations: { company: true, companyProfile: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||
async findForUser(userId: string): Promise<Invoice[]> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
return companyId ? this.findByCompany(companyId) : [];
|
||||
}
|
||||
|
||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||
async findByIdForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const companyId = await this.resolveCompanyId(userId);
|
||||
const invoice = await this.findById(id);
|
||||
if (!companyId || invoice.companyId !== companyId) {
|
||||
throw new NotFoundException(`Invoice ${id} not found`);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||
* ownership, then charges whichever open invoice the source currently has
|
||||
* (see {@link payInvoice}).
|
||||
*/
|
||||
async payInvoiceForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
opts: PayInvoiceOptions = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findByIdForUser(id, userId);
|
||||
return this.payInvoice(
|
||||
invoice.source as Freight.InvoiceSource,
|
||||
invoice.sourceId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `FRT-${ymd}-`;
|
||||
const [row] = await mg.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an invoice for any source (booking, demurrage, manual, …).
|
||||
*
|
||||
* Persists the header plus its lines in one transaction and assigns the next
|
||||
* sequential `invoice_number`. The total defaults to the sum of line amounts
|
||||
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
|
||||
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
|
||||
*
|
||||
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
|
||||
* invoice as part of a larger booking flow).
|
||||
*/
|
||||
async generateInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||
}
|
||||
|
||||
private async createInvoice(
|
||||
input: GenerateInvoiceInput,
|
||||
mg: EntityManager,
|
||||
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const currency = input.currency ?? "ETB";
|
||||
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||
|
||||
const lines = input.lines.map((l) => {
|
||||
const quantity = l.quantity ?? 1;
|
||||
const unitRate = l.unitRate ?? 0;
|
||||
return {
|
||||
chargeType: l.chargeType,
|
||||
description: l.description,
|
||||
quantity,
|
||||
unitRate,
|
||||
amount: l.amount ?? quantity * unitRate,
|
||||
currency: l.currency ?? currency,
|
||||
metadata: l.metadata ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const totalAmount =
|
||||
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||
|
||||
const dueAt =
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
|
||||
const invoice = await mg.save(
|
||||
mg.create(Invoice, {
|
||||
invoiceNumber,
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
type: input.type,
|
||||
companyId: input.companyId,
|
||||
companyProfileId: input.companyProfileId,
|
||||
totalAmount,
|
||||
currency,
|
||||
status,
|
||||
issuedAt: issued ? new Date() : null,
|
||||
dueAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const savedLines = await Promise.all(
|
||||
lines.map((l) =>
|
||||
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
|
||||
);
|
||||
|
||||
return { ...invoice, lines: savedLines };
|
||||
}
|
||||
|
||||
// ── State transitions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark an invoice paid and link the gateway payment, then emit
|
||||
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
|
||||
* No-op when the invoice is already paid. Pass `manager` to enlist in a
|
||||
* caller's transaction.
|
||||
*/
|
||||
async markInvoiceAsPaid(
|
||||
invoiceId: string,
|
||||
paymentId: string | null = null,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Paid,
|
||||
"paid",
|
||||
{ paymentId: paymentId ?? undefined },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||
* No-op when already refunded.
|
||||
*/
|
||||
async markInvoiceAsRefunded(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Refunded,
|
||||
"refunded",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||
* No-op when already cancelled.
|
||||
*/
|
||||
async cancelInvoice(
|
||||
invoiceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.transition(
|
||||
invoiceId,
|
||||
Freight.InvoiceStatus.Cancelled,
|
||||
"cancelled",
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||
* in the target status. Throws when the invoice does not exist.
|
||||
*
|
||||
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||
* outer transaction is passed, listeners run before that transaction commits.
|
||||
*/
|
||||
private async transition(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
event: string,
|
||||
extra: { paymentId?: string },
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
if (invoice.status === status) return invoice;
|
||||
|
||||
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||
|
||||
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||
this.emitInvoiceEvent(event, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||
private emitInvoiceEvent(event: string, invoice: Invoice): void {
|
||||
const payload: InvoiceEventPayload = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
source: invoice.source as Freight.InvoiceSource,
|
||||
sourceId: invoice.sourceId,
|
||||
type: invoice.type,
|
||||
companyId: invoice.companyId,
|
||||
companyProfileId: invoice.companyProfileId,
|
||||
totalAmount: invoice.totalAmount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The invoice a gateway payment should settle for a source record, or null if
|
||||
* none. This is the billing document of record for "what is owed" — callers
|
||||
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||
* carried on the invoice are honored.
|
||||
*
|
||||
* Pass `type` to select a specific invoice when a source carries several (e.g.
|
||||
* a booking's up-front vs final charge); omit it to settle whichever single
|
||||
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||
* non-cancelled) invoice.
|
||||
*/
|
||||
findPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
type?: string,
|
||||
): Promise<Invoice | null> {
|
||||
return this.dataSource.getRepository(Invoice).findOne({
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a source's currently-open invoice as paid and link the gateway
|
||||
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||
* settlement. No-op (returns null) when the source has no open invoice.
|
||||
*
|
||||
* Type-blind by design: settles whichever invoice is due; any per-type reaction
|
||||
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
|
||||
* Pass the caller's transaction `manager` to enlist in its DB transaction.
|
||||
*
|
||||
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
|
||||
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
|
||||
* for callers that settle by source rather than by gateway intent id.
|
||||
*/
|
||||
async settlePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
paymentId: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||
* No-op (returns null) when the source has no paid invoice.
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||
* to enlist in its DB transaction.
|
||||
*/
|
||||
async refundPayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||
}
|
||||
|
||||
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||
|
||||
/**
|
||||
* Charge a source's open invoice through the payment gateway. Billing is the
|
||||
* single place that turns "what is owed" (the invoice) into a payment intent —
|
||||
* the domain never talks to the payment service directly. Resolves the open
|
||||
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
|
||||
* the invoice (the settlement correlation key), and returns the client action.
|
||||
*
|
||||
* When the provider settles synchronously, the invoice is settled inline here —
|
||||
* after the intent id is stored — so the `payment.succeeded` correlation can
|
||||
* never fire before the link exists. Throws when the source has no open invoice.
|
||||
*/
|
||||
async payInvoice(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
} = {},
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.findPayable(source, sourceId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: sourceId,
|
||||
source: invoice.source,
|
||||
// Gateway reference type derives from the invoice source by convention
|
||||
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||
// the domain never supplies it. New sources add their uppercased value to
|
||||
// the PaymentReferenceType enum.
|
||||
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
result.intentId,
|
||||
result.providerTxnId,
|
||||
result.paidAt,
|
||||
);
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
||||
* payment service when an intent succeeds: finds the invoice linked by
|
||||
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||
* settled, or settled inline by {@link payInvoice}).
|
||||
*/
|
||||
async settleByPaymentId(
|
||||
paymentId: string,
|
||||
_providerTxnId?: string,
|
||||
_paidAt?: Date,
|
||||
): Promise<Invoice | null> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
|
||||
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** Gateway options for paying an invoice from the customer portal. */
|
||||
export class PayInvoiceDto {
|
||||
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
method?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: "web" | "mobile";
|
||||
|
||||
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payerAccount?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
returnUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Invoice } from "./invoice.entity";
|
||||
|
||||
@Entity({ schema: "freight", name: "invoice_lines" })
|
||||
export class InvoiceLine extends BaseEntity {
|
||||
@Column({ name: "invoice_id", type: "uuid", nullable: false })
|
||||
invoiceId!: string;
|
||||
|
||||
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "invoice_id" })
|
||||
invoice!: Invoice;
|
||||
|
||||
@Column({ name: "charge_type", type: "varchar", nullable: false })
|
||||
chargeType!: string;
|
||||
|
||||
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||
description?: string;
|
||||
|
||||
/** Units this line bills for (e.g. container count, wagon count, tons). */
|
||||
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
|
||||
quantity!: number;
|
||||
|
||||
/** Price per unit; `amount` is normally `quantity * unitRate`. */
|
||||
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||
unitRate!: number;
|
||||
|
||||
@Column({
|
||||
name: "amount",
|
||||
type: "numeric",
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
})
|
||||
amount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: "metadata", type: "jsonb", nullable: true })
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||
import { Company } from "../../companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||
|
||||
@Entity({schema:"freight", name: "invoices" })
|
||||
@Entity({ schema: "freight", name: "invoices" })
|
||||
@Index(["companyId"])
|
||||
@Index(["companyProfileId"])
|
||||
export class Invoice extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||
invoiceNumber!: string;
|
||||
|
||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
||||
amount!: number;
|
||||
/** The customer (company) this invoice is billed to. */
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||
@Column({ name: "company_profile_id", type: "uuid" })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile)
|
||||
@JoinColumn({ name: "company_profile_id" })
|
||||
companyProfile?: CompanyProfile;
|
||||
|
||||
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||
totalAmount!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: Freight.PaymentStatus,
|
||||
default: Freight.PaymentStatus.Pending,
|
||||
enum: Freight.InvoiceStatus,
|
||||
default: Freight.InvoiceStatus.Draft,
|
||||
})
|
||||
status!: Freight.PaymentStatus;
|
||||
status!: Freight.InvoiceStatus;
|
||||
|
||||
@Column({ name: "issued_at", type: "timestamptz" })
|
||||
issuedAt!: Date;
|
||||
/** The source of the payment (e.g. booking, customer, etc.). */
|
||||
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
|
||||
source!: string;
|
||||
|
||||
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
|
||||
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
|
||||
sourceId!: string;
|
||||
|
||||
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
|
||||
@Column({
|
||||
type: "varchar",
|
||||
length: 255,
|
||||
nullable: false,
|
||||
})
|
||||
type!: string;
|
||||
|
||||
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
|
||||
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||
issuedAt?: Date | null;
|
||||
|
||||
/** The ID of the payment that generated this invoice. */
|
||||
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||
paymentId?: string | null;
|
||||
|
||||
@ManyToOne(() => PaymentEntity)
|
||||
@JoinColumn({ name: "payment_id" })
|
||||
payment?: PaymentEntity;
|
||||
|
||||
@Column({ name: "due_at", type: "timestamptz" })
|
||||
dueAt!: Date;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
|
||||
constructor(
|
||||
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Invoice } from "./entities/invoice.entity";
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceRepository extends BaseRepository<Invoice> {
|
||||
constructor(
|
||||
@InjectRepository(Invoice) repository: Repository<Invoice>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
/**
|
||||
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||
* org-wide), every route here is force-scoped to the signed-in customer's
|
||||
* company — they only ever see and pay their own invoices.
|
||||
*/
|
||||
@ApiTags("billing")
|
||||
@ApiBearerAuth()
|
||||
@Controller("billing")
|
||||
export class PortalBillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get("my-invoices")
|
||||
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||
findMine(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("my-invoices/:id")
|
||||
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
|
||||
findMineById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/pay")
|
||||
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||
pay(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: PayInvoiceDto,
|
||||
) {
|
||||
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
|
||||
method: dto.method,
|
||||
platform: dto.platform ?? "web",
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
interface StoredPricingBreakdown {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
|
||||
* turns into invoices, which type to use, and how it advances when paid. Bookings
|
||||
* are the billable business entity, so they generate their own invoices directly
|
||||
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
|
||||
* type branching lives here, at the two points it belongs: invoice creation and
|
||||
* settlement (the paid handler).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingInvoiceService {
|
||||
private readonly logger = new Logger(BookingInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatch: BookingBatchService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Ensure the booking has its invoice, generating one from the snapshotted
|
||||
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
||||
* Idempotent — returns the existing open invoice instead of a duplicate.
|
||||
* Returns `null` (and logs) when the booking is not billable: no company to
|
||||
* bill (e.g. government bookings whose `companyId` is null, which the invoices
|
||||
* FK requires), or no priced amount.
|
||||
*/
|
||||
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
Freight.InvoiceType.Prepaid,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!booking.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = this.buildInput(booking);
|
||||
if (!input) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||
* reactions live here (not in the payment process): each invoice type advances
|
||||
* the booking its own way. Only PREPAID exists today.
|
||||
*/
|
||||
@OnEvent('booking.invoice.paid')
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
switch (payload.type) {
|
||||
case Freight.InvoiceType.Prepaid:
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
* and is allocated into its batch. Idempotent — no-op when already PAID.
|
||||
*
|
||||
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
|
||||
* lifecycle and ordering window live in the contracts module, advanced by the
|
||||
* contract transition/clearance services — not by booking payment. Every
|
||||
* booking that settles here is a ONE_TIME shipment, so there is no contract
|
||||
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
|
||||
*/
|
||||
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
||||
return;
|
||||
}
|
||||
if (booking.paymentStatus === 'PAID') return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: 'PAID', status: 'PAID' },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
||||
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
|
||||
|
||||
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||
chargeType: l.code,
|
||||
description: l.description,
|
||||
quantity: l.quantity,
|
||||
unitRate: l.unitAmount,
|
||||
amount: l.amount,
|
||||
currency: l.currency ?? currency,
|
||||
metadata: l.unit ? { unit: l.unit } : null,
|
||||
}));
|
||||
|
||||
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||
if (lines.length === 0) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||
lines.push({
|
||||
chargeType: 'FREIGHT',
|
||||
description: 'Rail freight',
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
|
||||
let totalAmount = subtotal;
|
||||
|
||||
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||
// as an ADJUSTMENT line so the lines still sum to the invoice total.
|
||||
const adjusted = booking.adjustedTotalAmount;
|
||||
if (adjusted != null && Number.isFinite(Number(adjusted))) {
|
||||
const delta = round2(Number(adjusted) - subtotal);
|
||||
if (delta !== 0) {
|
||||
lines.push({
|
||||
chargeType: 'ADJUSTMENT',
|
||||
description: 'Staff price adjustment',
|
||||
quantity: 1,
|
||||
unitRate: delta,
|
||||
amount: delta,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
totalAmount = round2(Number(adjusted));
|
||||
}
|
||||
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: booking.id,
|
||||
type: Freight.InvoiceType.Prepaid,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency,
|
||||
lines,
|
||||
totalAmount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
} from "../payment/payments.dto";
|
||||
|
||||
/**
|
||||
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||
*/
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class BookingPaymentController {
|
||||
constructor(private readonly billing: BillingService) { }
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: "Charges the booking's open invoice through the payment gateway.",
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||
method: dto.method,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.billing.payInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
{ method, platform },
|
||||
);
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,37 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Start payment for a booking. The booking never touches the payment gateway
|
||||
* directly — it charges its invoice through billing, which resolves the amount
|
||||
* and drives the provider. Returns the provider redirect URL (empty when none).
|
||||
*/
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await this.paymentService.initiatePayment({
|
||||
bookingId,
|
||||
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: "web",
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
||||
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
ruleEngineService as never,
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // invoiceService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
@@ -14,6 +15,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
private readonly logger = new Logger(BookingTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
@@ -404,7 +409,22 @@ export class BookingTransitionService {
|
||||
marketingApprovedAt: new Date(),
|
||||
lockedAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
|
||||
const executed = await this.bookingsService.findById(updated!.id);
|
||||
|
||||
// Billable state reached — generate the invoice payment will settle.
|
||||
// Non-blocking: a billing hiccup must not undo the execution.
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(executed)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for booking ${executed.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
|
||||
@@ -10,7 +10,11 @@ import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPaymentController } from './booking-payment.controller';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
@@ -35,7 +39,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
|
||||
@Module({
|
||||
@@ -51,7 +54,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
]),
|
||||
PaymentModule,
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
@@ -66,7 +70,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, BookingAllocationController, PayController],
|
||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -75,6 +79,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
@@ -82,6 +87,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -308,10 +308,23 @@ export class BookingsService {
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
if (!dto.governmentInstitution?.trim()) {
|
||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
||||
// Government bookings bill to a real seeded government company + an
|
||||
// explicitly-chosen importer/exporter profile (no more null company +
|
||||
// free-text institution).
|
||||
if (!dto.companyId) {
|
||||
throw new BadRequestException('A government company is required for government bookings');
|
||||
}
|
||||
companyId = dto.companyId ?? null;
|
||||
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
|
||||
if (govCompany.kind !== CompanyKind.Government) {
|
||||
throw new BadRequestException('Selected company is not a government entity');
|
||||
}
|
||||
if (govCompany.status !== CompanyStatus.Active) {
|
||||
throw new BadRequestException('Selected government company is not active');
|
||||
}
|
||||
if (!dto.companyProfileId) {
|
||||
throw new BadRequestException('A government company profile is required for government bookings');
|
||||
}
|
||||
companyId = govCompany.id;
|
||||
} else if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
@@ -384,7 +397,16 @@ export class BookingsService {
|
||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||
// for non-government bookings with a resolved company; never blocks creation.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
if (dto.companyProfileId && companyId) {
|
||||
// Explicit profile pin (government booking, or staff booking on behalf):
|
||||
// must belong to the chosen company and be active.
|
||||
const profile =
|
||||
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||
companyId,
|
||||
dto.companyProfileId,
|
||||
);
|
||||
companyProfileId = profile.id;
|
||||
} else if (companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
@@ -414,6 +436,16 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Every booking must link to a company and a company profile.
|
||||
if (!companyId) {
|
||||
throw new BadRequestException('A company is required to create a booking');
|
||||
}
|
||||
if (!companyProfileId) {
|
||||
throw new BadRequestException(
|
||||
'A company profile is required to create a booking — none could be resolved for this company',
|
||||
);
|
||||
}
|
||||
|
||||
const needsConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.needsConsolidation(containers)
|
||||
@@ -444,10 +476,10 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
companyId,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
governmentInstitution: dto.governmentInstitution?.trim() || null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
@@ -104,19 +103,31 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
/** @deprecated Government bookings now bill to a real government company. */
|
||||
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -106,8 +106,10 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
// Every booking is billed to a company — government bookings bill to a seeded
|
||||
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
@@ -117,11 +119,12 @@ export class Booking extends BaseEntity {
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
||||
* legacy/government/staff-created bookings.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Required:
|
||||
* commercial bookings resolve it from trade direction / active mode;
|
||||
* government bookings carry the explicitly-picked government profile.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||
companyProfileId!: string;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
|
||||
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
async findPaginated(
|
||||
query: ListCompaniesQueryDto,
|
||||
): Promise<{ items: Company[]; total: number }> {
|
||||
const { page = 1, pageSize = 20, search, type, status } = query;
|
||||
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.type = :type', { type });
|
||||
}
|
||||
|
||||
if (kind) {
|
||||
qb.andWhere('company.kind = :kind', { kind });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
@@ -337,6 +337,29 @@ export class CompaniesService {
|
||||
return company;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||
* to the booking's company and be Active. Used for government bookings (staff
|
||||
* pick the profile) and any staff booking that pins a profile directly.
|
||||
*/
|
||||
async getActiveCompanyProfileForBooking(
|
||||
companyId: string,
|
||||
profileId: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!profile || profile.companyId !== companyId) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile does not belong to the chosen company",
|
||||
);
|
||||
}
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
throw new BadRequestException(
|
||||
"Selected company profile is not active",
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyType))
|
||||
type?: CompanyType;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyKind })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyKind))
|
||||
kind?: CompanyKind;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
|
||||
@@ -10,6 +10,16 @@ export enum CompanyType {
|
||||
Transporter = "transporter",
|
||||
}
|
||||
|
||||
/**
|
||||
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
|
||||
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
|
||||
* of carrying a null company + free-text institution.
|
||||
*/
|
||||
export enum CompanyKind {
|
||||
Commercial = "commercial",
|
||||
Government = "government",
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
@@ -25,6 +35,7 @@ export enum CompanyNationality {
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
@Index(["kind"])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
/** Commercial customer vs. the seeded government entity. */
|
||||
@Column({
|
||||
name: "kind",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
default: CompanyKind.Commercial,
|
||||
enum: CompanyKind,
|
||||
})
|
||||
kind!: CompanyKind;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBookingService {
|
||||
private readonly logger = new Logger(ContractBookingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@@ -52,6 +56,7 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -199,6 +204,22 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
|
||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
|
||||
type PaymentType = "booking"
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
||||
refId!: string
|
||||
|
||||
@Column({ type: "enum", enum: ["booking"] })
|
||||
@Column({ type: "varchar", length: 50 })
|
||||
type!: PaymentType;
|
||||
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
Body,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -20,14 +20,7 @@ import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
@@ -73,16 +66,6 @@ export class PaymentController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||
return this.paymentService.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
@ApiOkResponse({ type: IntentStatusDto })
|
||||
@@ -97,54 +80,6 @@ export class PaymentController {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
@@ -153,76 +88,4 @@ export class PaymentController {
|
||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||
return res.status(HttpStatus.OK).type("html").send(html);
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
||||
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
} from "@edr/types";
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => BillingModule),
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
@@ -28,13 +29,44 @@ import {
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||
* the caller (billing) — this service never derives them from a domain record. */
|
||||
export interface InitiateIntentInput {
|
||||
/** Opaque domain reference (booking id, …). */
|
||||
referenceId: string;
|
||||
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
|
||||
source: string;
|
||||
/** Gateway reference type the intent is opened with (caller's domain decides it). */
|
||||
referenceType: PaymentReferenceType;
|
||||
/** Human-readable order ref shown on provider pages. */
|
||||
orderRef: string;
|
||||
/** Authoritative amount in minor units, computed by the caller. */
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
/** Stored on the intent projection for receipts/dashboards. */
|
||||
reason?: string;
|
||||
/** Provider/method selector. */
|
||||
method: ProviderMethod | string;
|
||||
platform?: PaymentPlatformDto;
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface InitiateIntentResult {
|
||||
intentId: string;
|
||||
response: InitiateResponseDto;
|
||||
/** True when the provider settled the charge synchronously during initiate. */
|
||||
immediateSuccess: boolean;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
|
||||
* NOTHING domain-specific: it never loads a booking, computes an amount, or
|
||||
* advances a domain record. On settlement it notifies billing directly
|
||||
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
|
||||
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
@@ -53,9 +102,8 @@ export class PaymentService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
async getAll(filters: {
|
||||
@@ -123,7 +171,6 @@ export class PaymentService {
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
// Sum of successfully collected amounts.
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
@@ -141,69 +188,74 @@ export class PaymentService {
|
||||
};
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
/**
|
||||
* Open a gateway intent for a caller-supplied amount/reference and project it
|
||||
* locally. Returns the intent id (so billing can correlate the invoice) plus
|
||||
* the client action. When the provider settles synchronously, the intent is
|
||||
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: booking.paymentCurrency,
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
referenceType: input.referenceType,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
await this.datasource.getRepository(Booking).update(
|
||||
{ id: dto.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentResponse(intent);
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
booking: Booking,
|
||||
/** Create or update the local intent projection from a provider snapshot. */
|
||||
private async upsertIntent(
|
||||
input: InitiateIntentInput,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
): Promise<PaymentEntity> {
|
||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
const existing = await this.paymentRepo.findOneBy({
|
||||
refId: input.referenceId,
|
||||
});
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
const status =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
@@ -220,30 +272,37 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
return this.paymentRepo.create({
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
reason: `Payment for booking ${booking.reference}`,
|
||||
refId: input.referenceId,
|
||||
type: input.source,
|
||||
referenceType: input.referenceType,
|
||||
amount: input.amountMinor,
|
||||
currency: input.currency as PaymentEntity["currency"],
|
||||
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||
clientAction: clientAction ?? {},
|
||||
...data,
|
||||
} as any);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
/**
|
||||
* Reconcile an intent's status with the gateway by reference. Read-only on the
|
||||
* domain side: it syncs the local projection and, when the provider reports a
|
||||
* newly-observed success, notifies billing to settle. `referenceId` is opaque
|
||||
* (the booking id, but this service does not load it).
|
||||
*/
|
||||
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
bookingId,
|
||||
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
|
||||
referenceId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
||||
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,75 +310,54 @@ export class PaymentService {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: bookingId });
|
||||
// Sync local projection with provider-reported status.
|
||||
const becameSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
|
||||
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
if (becameSuccess) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.paymentRepo.update(
|
||||
{ id: local.id },
|
||||
{
|
||||
status: this.toLocalStatus(snapshot.status),
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
|
||||
return this.formatIntentStatus(refreshed ?? intent);
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
/**
|
||||
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||
* when the caller settles inline and will trigger settlement itself.
|
||||
*/
|
||||
async markIntentSucceeded(
|
||||
intentId: string,
|
||||
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
|
||||
): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
const paidAt = opts.paidAt ?? new Date();
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
bookingId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
// if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
// if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
||||
// so payment always settles the booking to PAID and enters allocation.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
// await mg.update(
|
||||
// PaymentEntity,
|
||||
// // { id: intent.id },
|
||||
// {id:input.intentId},
|
||||
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
// );
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (opts.notify !== false) {
|
||||
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
@@ -338,6 +376,29 @@ export class PaymentService {
|
||||
{ id: intent.id },
|
||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
||||
);
|
||||
|
||||
// Invoice stays open for retry — nothing to settle. Logged only.
|
||||
this.logger.warn(
|
||||
`Payment ${intent.id} failed for ${intent.refId}` +
|
||||
(input.failureMessage ? `: ${input.failureMessage}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
// NOTE: refunding still mutates the booking directly — left intact pending
|
||||
// the refund redesign. TODO: route refunds through billing.refundPayable +
|
||||
// a `${source}.invoice.refunded` reaction, like settlement.
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
@@ -366,7 +427,7 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
findBookingById(id: string) {
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
||||
return this.paymentRepo.findOneBy({ refId: id });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
@@ -401,46 +462,34 @@ export class PaymentService {
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId:event.intentId,
|
||||
bookingId: event.referenceId,
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||
}
|
||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
return { processed: true, alreadyFinalized };
|
||||
// console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
// if (event.eventType === "payment.succeeded") {
|
||||
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
|
||||
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
// if (!intent) {
|
||||
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
// }
|
||||
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
|
||||
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
// intentId: intent.id,
|
||||
// bookingId: event.referenceId,
|
||||
// providerTxnId: event.providerTxnId,
|
||||
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
// });
|
||||
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||
// return { processed: true, alreadyFinalized };
|
||||
// }
|
||||
}
|
||||
|
||||
// if (event.eventType === "payment.failed") {
|
||||
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
// if (!intent) {
|
||||
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
// }
|
||||
// await this.markPaymentFailed({
|
||||
// intentId: intent.id,
|
||||
// failureCode: event.failureCode,
|
||||
// failureMessage: event.failureMessage,
|
||||
// });
|
||||
// return { processed: true };
|
||||
// }
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||
}
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: event.failureCode,
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
}
|
||||
|
||||
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
|
||||
|
||||
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import "reflect-metadata";
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
config({ path: resolve(__dirname, "../../.env") });
|
||||
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "../app.module";
|
||||
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ["error", "warn", "log"],
|
||||
});
|
||||
|
||||
try {
|
||||
const seeder = app.get(GovCompaniesSeeder);
|
||||
await seeder.run();
|
||||
console.log("Government companies seeded.");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Government companies seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
CompanyKind,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../../modules/companies/entities/company.entity";
|
||||
import {
|
||||
ProfileStatus,
|
||||
ProfileType,
|
||||
} from "../../modules/companies/entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* Canonical list of seeded Ethiopian government entities. Government bookings
|
||||
* are billed to one of these (with an explicit importer/exporter profile)
|
||||
* instead of carrying a null company + free-text institution.
|
||||
*
|
||||
* IDs are fixed so the seeder is idempotent and the matching migration
|
||||
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
|
||||
* the same companies. The migration mirrors these rows in raw SQL — keep both
|
||||
* in sync when adding new entities.
|
||||
*/
|
||||
|
||||
export const GOV_COMPANY_TYPE = CompanyType.Customer;
|
||||
export const GOV_COMPANY_KIND = CompanyKind.Government;
|
||||
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
|
||||
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
|
||||
|
||||
export interface GovProfileSeed {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
export interface GovCompanySeed {
|
||||
id: string;
|
||||
name: string;
|
||||
tin: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
profiles: GovProfileSeed[];
|
||||
}
|
||||
|
||||
const importExport = (
|
||||
index: number,
|
||||
importerId: string,
|
||||
exporterId: string,
|
||||
): GovProfileSeed[] => [
|
||||
{
|
||||
id: importerId,
|
||||
type: ProfileType.importer,
|
||||
reference: `IM-9000${index}`,
|
||||
},
|
||||
{
|
||||
id: exporterId,
|
||||
type: ProfileType.exporter,
|
||||
reference: `EX-9000${index}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const GOV_COMPANIES: GovCompanySeed[] = [
|
||||
{
|
||||
id: "0a1b0001-0000-4000-8000-000000000001",
|
||||
name: "Federal Government of Ethiopia",
|
||||
tin: "0000000001",
|
||||
email: "procurement@gov.et",
|
||||
phone: "+251111000001",
|
||||
profiles: importExport(
|
||||
1,
|
||||
"0b1c0001-0000-4000-8000-000000000001",
|
||||
"0b1c0001-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0002-0000-4000-8000-000000000002",
|
||||
name: "Ministry of National Defense",
|
||||
tin: "0000000002",
|
||||
email: "logistics@mod.gov.et",
|
||||
phone: "+251111000002",
|
||||
profiles: importExport(
|
||||
2,
|
||||
"0b1c0002-0000-4000-8000-000000000001",
|
||||
"0b1c0002-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0003-0000-4000-8000-000000000003",
|
||||
name: "Ethiopian Roads Administration",
|
||||
tin: "0000000003",
|
||||
email: "supply@era.gov.et",
|
||||
phone: "+251111000003",
|
||||
profiles: importExport(
|
||||
3,
|
||||
"0b1c0003-0000-4000-8000-000000000001",
|
||||
"0b1c0003-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0004-0000-4000-8000-000000000004",
|
||||
name: "Ministry of Agriculture",
|
||||
tin: "0000000004",
|
||||
email: "imports@moa.gov.et",
|
||||
phone: "+251111000004",
|
||||
profiles: importExport(
|
||||
4,
|
||||
"0b1c0004-0000-4000-8000-000000000001",
|
||||
"0b1c0004-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0005-0000-4000-8000-000000000005",
|
||||
name: "Ministry of Trade and Regional Integration",
|
||||
tin: "0000000005",
|
||||
email: "trade@motri.gov.et",
|
||||
phone: "+251111000005",
|
||||
profiles: importExport(
|
||||
5,
|
||||
"0b1c0005-0000-4000-8000-000000000001",
|
||||
"0b1c0005-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "0a1b0006-0000-4000-8000-000000000006",
|
||||
name: "Ethiopian Disaster Risk Management Commission",
|
||||
tin: "0000000006",
|
||||
email: "relief@edrmc.gov.et",
|
||||
phone: "+251111000006",
|
||||
profiles: importExport(
|
||||
6,
|
||||
"0b1c0006-0000-4000-8000-000000000001",
|
||||
"0b1c0006-0000-4000-8000-000000000002",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Fallback entity used to backfill legacy government / null-company bookings. */
|
||||
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
|
||||
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];
|
||||
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Company } from "../modules/companies/entities/company.entity";
|
||||
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
|
||||
import {
|
||||
GOV_COMPANIES,
|
||||
GOV_COMPANY_KIND,
|
||||
GOV_COMPANY_STATUS,
|
||||
GOV_COMPANY_TYPE,
|
||||
GOV_PROFILE_STATUS,
|
||||
} from "./data/gov-companies.data";
|
||||
|
||||
/**
|
||||
* Idempotently seeds the Ethiopian government entities (with importer + exporter
|
||||
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
|
||||
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GovCompaniesSeeder {
|
||||
private readonly logger = new Logger(GovCompaniesSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
|
||||
for (const gov of GOV_COMPANIES) {
|
||||
const existing = await companyRepo.findOne({ where: { id: gov.id } });
|
||||
if (!existing) {
|
||||
await companyRepo.save(
|
||||
companyRepo.create({
|
||||
id: gov.id,
|
||||
name: gov.name,
|
||||
type: GOV_COMPANY_TYPE,
|
||||
kind: GOV_COMPANY_KIND,
|
||||
status: GOV_COMPANY_STATUS,
|
||||
tin: gov.tin,
|
||||
country: "Ethiopia",
|
||||
email: gov.email,
|
||||
phone: gov.phone,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Created government company: ${gov.name}`);
|
||||
}
|
||||
|
||||
for (const profile of gov.profiles) {
|
||||
const existingProfile = await profileRepo.findOne({
|
||||
where: { id: profile.id },
|
||||
});
|
||||
if (existingProfile) continue;
|
||||
await profileRepo.save(
|
||||
profileRepo.create({
|
||||
id: profile.id,
|
||||
companyId: gov.id,
|
||||
type: profile.type,
|
||||
reference: profile.reference,
|
||||
status: GOV_PROFILE_STATUS,
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log("Government companies seeded.");
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,10 @@ export default function NewBookingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isGovernment, setIsGovernment] = useState(false);
|
||||
const [governmentInstitution, setGovernmentInstitution] = useState("");
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
// Government bookings bill to a real government company + an explicit profile.
|
||||
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
||||
const [govProfileId, setGovProfileId] = useState<string | null>(null);
|
||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
|
||||
label: c.name || c.email || c.tin || c.id,
|
||||
}));
|
||||
|
||||
// Active government companies (kind=government) the booking can bill to.
|
||||
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
|
||||
queryKey: ["companies", "government", "active"],
|
||||
queryFn: () =>
|
||||
customersService.list({
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
kind: "government",
|
||||
status: "active",
|
||||
}),
|
||||
enabled: isGovernment,
|
||||
});
|
||||
|
||||
const govCompanies = govCompaniesPage?.items ?? [];
|
||||
const govCompanyOptions = govCompanies.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name || c.tin || c.id,
|
||||
}));
|
||||
|
||||
// Profiles (importer/exporter) of the chosen government company — the booking
|
||||
// must link to one explicitly.
|
||||
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
|
||||
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
|
||||
.filter((p) => p.status === "active")
|
||||
.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
|
||||
p.reference ? ` — ${p.reference}` : ""
|
||||
}`,
|
||||
}));
|
||||
|
||||
// Reset the chosen profile when the government company changes.
|
||||
useEffect(() => {
|
||||
setGovProfileId(null);
|
||||
}, [govCompanyId]);
|
||||
|
||||
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||
// train, no capacity). The batch engine assigns the train after booking.
|
||||
const { data: availableDays, isLoading: daysLoading } = useQuery(
|
||||
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
|
||||
Boolean(tradeDirection) &&
|
||||
Boolean(serviceTypeId) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
: allLinesValid);
|
||||
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
|
||||
mutationFn: () =>
|
||||
bookingsService.create({
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? governmentInstitution : undefined,
|
||||
companyId: isGovernment ? undefined : companyId || undefined,
|
||||
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
|
||||
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
|
||||
freightType,
|
||||
contractType: "NEW",
|
||||
equipmentReturn,
|
||||
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
label="Government booking"
|
||||
description="No company required — institution name instead. Expedited to the scheduling queue."
|
||||
description="Bills to a government entity + profile. Expedited to the scheduling queue."
|
||||
checked={isGovernment}
|
||||
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
|
||||
/>
|
||||
{isGovernment ? (
|
||||
<TextInput
|
||||
label="Government institution"
|
||||
placeholder="e.g. Ministry of Transport"
|
||||
value={governmentInstitution}
|
||||
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Government entity"
|
||||
placeholder="Select government company"
|
||||
data={govCompanyOptions}
|
||||
value={govCompanyId}
|
||||
onChange={setGovCompanyId}
|
||||
searchable
|
||||
required
|
||||
disabled={govCompaniesLoading}
|
||||
nothingFoundMessage="No active government companies"
|
||||
/>
|
||||
<Select
|
||||
label="Profile"
|
||||
placeholder={
|
||||
govCompanyId ? "Select import/export profile" : "Pick an entity first"
|
||||
}
|
||||
data={govProfileOptions}
|
||||
value={govProfileId}
|
||||
onChange={setGovProfileId}
|
||||
searchable
|
||||
required
|
||||
disabled={!govCompanyId}
|
||||
nothingFoundMessage="No active profiles for this entity"
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Select
|
||||
label="Customer"
|
||||
|
||||
@@ -18,6 +18,9 @@ export type CompanyType =
|
||||
/** Mirrors backend `CompanyStatus`. */
|
||||
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||
|
||||
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
|
||||
export type CompanyKind = "commercial" | "government";
|
||||
|
||||
/** Mirrors backend `ProfileType` (the role a company plays). */
|
||||
export type ProfileType =
|
||||
| "importer"
|
||||
@@ -47,6 +50,7 @@ export interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CompanyType;
|
||||
kind: CompanyKind;
|
||||
status: CompanyStatus;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
@@ -73,6 +77,7 @@ export interface CompanyListFilter {
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
type?: CompanyType;
|
||||
kind?: CompanyKind;
|
||||
status?: CompanyStatus;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage";
|
||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||
import SignupPage from "./pages/accounts/SignupPage";
|
||||
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
||||
import BillingPage from "./pages/billing/BillingPage";
|
||||
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
|
||||
import InvoicesList from "./pages/billing/InvoicesList";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
@@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [
|
||||
icon: <MapPin size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Billing",
|
||||
label: "Invoices",
|
||||
href: "/billing",
|
||||
icon: <Receipt size={18} />,
|
||||
},
|
||||
@@ -285,7 +286,8 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
|
||||
@@ -143,4 +143,10 @@ export const URL_CONSTANTS = {
|
||||
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
||||
CHECKOUT: "/api/payments/checkout",
|
||||
},
|
||||
|
||||
BILLING: {
|
||||
MY_INVOICES: "/api/billing/my-invoices",
|
||||
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
||||
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
|
||||
23
apps/edr-freight-web/portal/src/lib/currency.ts
Normal file
23
apps/edr-freight-web/portal/src/lib/currency.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
|
||||
export type Currency = string;
|
||||
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
USD: "$",
|
||||
ETB: "Br",
|
||||
DJF: "DJF",
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
|
||||
* Unknown currency codes fall back to printing the raw code.
|
||||
*/
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency: Currency = "ETB",
|
||||
): string {
|
||||
const symbol = SYMBOLS[currency] ?? currency;
|
||||
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||
|
||||
/**
|
||||
* Mock "logged-in customer". When auth integrates, replace this with the value
|
||||
* pulled from `@edr/iamui-common` / the JWT context.
|
||||
*/
|
||||
const CURRENT_CUSTOMER_ID = 1;
|
||||
|
||||
export function getCurrentCustomer(): Customer {
|
||||
return (
|
||||
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
|
||||
(customers[0] as Customer)
|
||||
);
|
||||
}
|
||||
|
||||
export function getMyInvoices(): Invoice[] {
|
||||
const me = getCurrentCustomer();
|
||||
return invoices.filter((inv) => inv.customerId === me.id);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import type { Currency } from "@/lib/currency";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { Group, Grid, Select, Stack } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -34,7 +34,6 @@ export default function MyPortalPage() {
|
||||
totalOutstanding,
|
||||
companyName,
|
||||
greeting,
|
||||
recentInvoices,
|
||||
dashboard,
|
||||
volumePoints,
|
||||
maxVolume,
|
||||
@@ -120,7 +119,7 @@ export default function MyPortalPage() {
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<InvoicesSection invoices={recentInvoices} />
|
||||
<InvoicesSection invoices={outstandingInvoices} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Group, Skeleton, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import type { Currency } from "@/lib/currency";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { formatPct } from "../constants";
|
||||
import { Card } from "./Card";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import type { PortalInvoice } from "@/services/invoices.service";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
|
||||
import { Freight } from "@edr/types";
|
||||
import { ChevronRight, Clock3 } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cv, INVOICE_BADGE } from "../constants";
|
||||
@@ -10,26 +10,26 @@ import { Card } from "./Card";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
interface InvoicesSectionProps {
|
||||
invoices: Array<{
|
||||
id: number;
|
||||
number: string;
|
||||
bookingReference: string;
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
status: InvoiceStatus;
|
||||
dueDate: string;
|
||||
paidDate: string | null;
|
||||
}>;
|
||||
invoices: PortalInvoice[];
|
||||
}
|
||||
|
||||
/** Max rows to list in the compact dashboard card. */
|
||||
const MAX_ROWS = 5;
|
||||
|
||||
const titleCase = (v: string) =>
|
||||
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
|
||||
|
||||
export const InvoicesSection = memo(function InvoicesSection({
|
||||
invoices,
|
||||
}: InvoicesSectionProps) {
|
||||
const outstandingInvoices = invoices.filter(
|
||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||
// Dashboard shows unpaid invoices only (pending + overdue).
|
||||
const pendingInvoices = invoices.filter(
|
||||
(inv) =>
|
||||
inv.status === Freight.InvoiceStatus.Pending ||
|
||||
inv.status === Freight.InvoiceStatus.Overdue,
|
||||
);
|
||||
const totalOutstanding = outstandingInvoices.reduce(
|
||||
(sum, inv) => sum + inv.amount,
|
||||
const totalOutstanding = pendingInvoices.reduce(
|
||||
(sum, inv) => sum + Number(inv.totalAmount),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({
|
||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||
{formatCurrency(totalOutstanding || 0, "ETB")}
|
||||
</Text>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mt={8}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text fz={12} c="edr-amber-text">
|
||||
{outstandingInvoices.length || 2} invoices unpaid
|
||||
</Text>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
px={14}
|
||||
py={8}
|
||||
bg="edr-accent"
|
||||
className="cursor-pointer rounded-[9px]"
|
||||
>
|
||||
<Zap size={15} color="#fff" />
|
||||
<Text fz={13} fw={700} c="white">
|
||||
Pay all
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz={12} mt={8} c="edr-amber-text">
|
||||
{pendingInvoices.length} invoices unpaid
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<EmptyState message="No invoices yet." />
|
||||
{pendingInvoices.length === 0 ? (
|
||||
<EmptyState message="No pending invoices." />
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{invoices.map((invoice, i) => {
|
||||
{pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => {
|
||||
const badge = INVOICE_BADGE[invoice.status];
|
||||
const dueText =
|
||||
invoice.status === "Paid"
|
||||
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
|
||||
: invoice.status === "Overdue"
|
||||
? "Overdue 3 days"
|
||||
: `Due ${invoice.dueDate}`;
|
||||
const DueIcon =
|
||||
invoice.status === "Paid" ? CheckCircle2 : Clock3;
|
||||
const dueIconColor =
|
||||
invoice.status === "Paid"
|
||||
? cv("edr-green.5")
|
||||
: cv("edr-muted");
|
||||
const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
|
||||
const dueText = isOverdue
|
||||
? "Overdue"
|
||||
: `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
|
||||
const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted");
|
||||
|
||||
return (
|
||||
<Box key={invoice.id}>
|
||||
{i > 0 && <Box h={1} bg="edr-divider" />}
|
||||
<Stack gap={8} py={10}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
<Link
|
||||
to={`/billing/${invoice.id}`}
|
||||
className="no-underline"
|
||||
style={{ display: "block", color: "inherit" }}
|
||||
>
|
||||
<Stack
|
||||
gap={8}
|
||||
py={10}
|
||||
className="cursor-pointer rounded-[9px] transition-colors hover:bg-[var(--mantine-color-edr-soft-0)]"
|
||||
>
|
||||
<Box>
|
||||
<Text fz={13} fw={700} c="edr-text">
|
||||
{invoice.number}
|
||||
</Text>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{invoice.bookingReference}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
{formatCurrency(invoice.amount, invoice.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={5} align="center">
|
||||
<DueIcon size={13} color={dueIconColor} />
|
||||
<Text fz={12} c="edr-muted">
|
||||
{dueText}
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text fz={13} fw={700} c="edr-text">
|
||||
{invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{titleCase(invoice.source)} · {titleCase(invoice.type)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
{formatCurrency(
|
||||
Number(invoice.totalAmount),
|
||||
invoice.currency,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
bg={badge.bg}
|
||||
px={10}
|
||||
py={4}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Text fz={11} fw={700} c={badge.text}>
|
||||
{badge.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={5} align="center">
|
||||
<Clock3 size={13} color={dueIconColor} />
|
||||
<Text fz={12} c="edr-muted">
|
||||
{dueText}
|
||||
</Text>
|
||||
</Group>
|
||||
{badge && (
|
||||
<Box
|
||||
bg={badge.bg}
|
||||
px={10}
|
||||
py={4}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Text fz={11} fw={700} c={badge.text}>
|
||||
{badge.label}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
export const cv = (token: string) => {
|
||||
const [name, shade] = token.split(".");
|
||||
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
|
||||
};
|
||||
|
||||
export const INVOICE_BADGE: Record<
|
||||
InvoiceStatus,
|
||||
Freight.InvoiceStatus,
|
||||
{ label: string; bg: string; text: string }
|
||||
> = {
|
||||
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
||||
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
||||
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
||||
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
||||
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { getMyInvoices } from "@/lib/currentCustomer";
|
||||
import { api } from "@/services/api";
|
||||
import { ACTIVE_STATUSES } from "./constants";
|
||||
|
||||
export function useMyPortalData(selectedProfileId?: string) {
|
||||
const { user, customer, company } = useAuth();
|
||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||
|
||||
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
|
||||
const myInvoices = invoicesQuery.data ?? [];
|
||||
|
||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||
|
||||
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
).length;
|
||||
|
||||
const outstandingInvoices = myInvoices.filter(
|
||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||
(inv) =>
|
||||
inv.status === Freight.InvoiceStatus.Pending ||
|
||||
inv.status === Freight.InvoiceStatus.Overdue,
|
||||
);
|
||||
|
||||
const totalOutstanding = outstandingInvoices.reduce(
|
||||
(sum, inv) => sum + inv.amount,
|
||||
(sum, inv) => sum + Number(inv.totalAmount),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
contractsQuery,
|
||||
invoicesQuery,
|
||||
allContracts,
|
||||
recentContracts,
|
||||
activeContractsCount,
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Download,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Receipt,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import NewInvoicePage from "./NewInvoicePage";
|
||||
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
|
||||
import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type FilterValue = "All" | InvoiceStatus;
|
||||
|
||||
const FILTERS: FilterValue[] = [
|
||||
"All",
|
||||
"Draft",
|
||||
"Sent",
|
||||
"Paid",
|
||||
"Overdue",
|
||||
"Cancelled",
|
||||
];
|
||||
|
||||
export default function BillingPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [filter, setFilter] = useState<FilterValue>("All");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return invoices.filter((inv) => {
|
||||
if (filter !== "All" && inv.status !== filter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
inv.number.toLowerCase().includes(q) ||
|
||||
inv.customer.toLowerCase().includes(q) ||
|
||||
inv.bookingReference.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [filter, query]);
|
||||
|
||||
const total = filtered.length;
|
||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
const end = Math.min(start + pagination.pageSize, total);
|
||||
|
||||
const paginatedData = useMemo(
|
||||
() => filtered.slice(start, end),
|
||||
[start, end, filtered],
|
||||
);
|
||||
|
||||
const totalRevenue = invoices
|
||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||
const outstanding = invoices
|
||||
.filter(
|
||||
(inv) =>
|
||||
(inv.status === "Sent" || inv.status === "Overdue") &&
|
||||
inv.currency === "USD",
|
||||
)
|
||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||
const overdueCount = invoices.filter(
|
||||
(inv) => inv.status === "Overdue",
|
||||
).length;
|
||||
|
||||
const columns: ColumnDef<(typeof invoices)[number]>[] = [
|
||||
{
|
||||
id: "invoice",
|
||||
header: "Invoice",
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Receipt />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{inv.number}</p>
|
||||
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customer",
|
||||
header: "Customer",
|
||||
},
|
||||
{
|
||||
accessorKey: "bookingReference",
|
||||
header: "Booking",
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => {
|
||||
const inv = row.original;
|
||||
return (
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{formatCurrency(inv.amount, inv.currency)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "dueDate",
|
||||
header: "Due Date",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 40,
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<NewInvoicePage
|
||||
mode="edit"
|
||||
invoice={{
|
||||
number: invoice.number,
|
||||
customerId: invoice.customerId,
|
||||
bookingReference: invoice.bookingReference,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status,
|
||||
issueDate: invoice.issueDate,
|
||||
dueDate: invoice.dueDate,
|
||||
notes: invoice.notes,
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</NewInvoicePage>
|
||||
<DropdownMenuSeparator />
|
||||
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e: Event) => e.preventDefault()}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Void
|
||||
</DropdownMenuItem>
|
||||
</DeleteInvoiceDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6">
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs items={[{ label: "Billing" }]} />
|
||||
|
||||
<Card className="p-6 flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Billing
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-foreground">
|
||||
Manage invoices, payments, and financial records.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
placeholder="Search invoices..."
|
||||
className="pl-8!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewInvoicePage>
|
||||
<Button>
|
||||
<Plus />
|
||||
New Invoice
|
||||
</Button>
|
||||
</NewInvoicePage>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total Revenue (USD)</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{formatCurrency(totalRevenue, "USD")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<DollarSign />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Outstanding (USD)</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{formatCurrency(outstanding, "USD")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Clock />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Overdue Invoices</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{overdueCount}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
|
||||
<AlertCircle />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{FILTERS.map((f) => {
|
||||
const isActive = f === filter;
|
||||
const count =
|
||||
f === "All"
|
||||
? invoices.length
|
||||
: invoices.filter((inv) => inv.status === f).length;
|
||||
return (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilter(f);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
className={
|
||||
isActive
|
||||
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
||||
}
|
||||
>
|
||||
{f}
|
||||
<span
|
||||
className={
|
||||
isActive
|
||||
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
||||
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||
<div>
|
||||
<CardTitle>Invoices</CardTitle>
|
||||
<CardDescription>
|
||||
Issued invoices and their payment status.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" size="sm">
|
||||
<Filter />
|
||||
Filter
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
status="success"
|
||||
onRowClick={() => { }}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-b shadow-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: InvoiceStatus }) {
|
||||
const styles: Record<InvoiceStatus, string> = {
|
||||
Draft: "bg-slate-100 text-slate-600",
|
||||
Sent: "bg-sky-100 text-sky-700",
|
||||
Paid: "bg-emerald-100 text-emerald-700",
|
||||
Overdue: "bg-red-100 text-red-700",
|
||||
Cancelled: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface DeleteInvoiceDialogProps {
|
||||
invoiceNumber: string;
|
||||
onConfirm?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function DeleteInvoiceDialog({
|
||||
invoiceNumber,
|
||||
onConfirm,
|
||||
children,
|
||||
}: DeleteInvoiceDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
Void invoice?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
This will void invoice{" "}
|
||||
<span className="font-semibold text-slate-900">
|
||||
{invoiceNumber}
|
||||
</span>
|
||||
. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Void Invoice
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, CreditCard, Info } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
import {
|
||||
billedTo,
|
||||
fmtDate,
|
||||
InvoiceStatusBadge,
|
||||
isPayable,
|
||||
titleCase,
|
||||
} from "./invoice-ui";
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={14} mt={4} style={{ color: INK }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: invoice, isLoading, isError } = useQuery(
|
||||
api.invoices.get.queryOptions({ input: { id } }),
|
||||
);
|
||||
|
||||
const payMutation = useMutation(
|
||||
api.invoices.pay.mutationOptions({
|
||||
onSuccess: (res) => {
|
||||
const url = res.clientAction?.url;
|
||||
if (url) window.location.href = url;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py={80}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !invoice) {
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px" }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/billing")}
|
||||
mb="md"
|
||||
>
|
||||
Back to invoices
|
||||
</Button>
|
||||
<Alert color="red" title="Invoice not found">
|
||||
We couldn't load this invoice. It may not exist or you may not have
|
||||
access to it.
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const payable = isPayable(invoice.status);
|
||||
const lines = invoice.lines ?? [];
|
||||
|
||||
const handlePay = () => {
|
||||
const returnUrl = `${window.location.origin}/payment/success`;
|
||||
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/billing")}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
styles={{ root: { fontWeight: 600 } }}
|
||||
>
|
||||
Back to invoices
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap={12} align="center" wrap="wrap">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
{invoice.invoiceNumber}
|
||||
</Title>
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</Group>
|
||||
{payable && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
loading={payMutation.isPending}
|
||||
onClick={handlePay}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
>
|
||||
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{payMutation.isError && (
|
||||
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
|
||||
Please try again, or contact support if the problem persists.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
<MetaItem label="Billed To" value={billedTo(invoice)} />
|
||||
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
|
||||
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
|
||||
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my="lg" color={BORDER} />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Total
|
||||
</Text>
|
||||
<Text fz={24} fw={800} style={{ color: INK }}>
|
||||
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Line items */}
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||
>
|
||||
<Box px="lg" py="md" style={{ borderBottom: `1px solid ${BORDER}` }}>
|
||||
<Text fz={15} fw={700} style={{ color: INK }}>
|
||||
Line items
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
verticalSpacing={12}
|
||||
horizontalSpacing={20}
|
||||
styles={{
|
||||
th: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: MUTED,
|
||||
background: "#F8FAFC",
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
td: { borderBottom: `1px solid ${BORDER}` },
|
||||
}}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Charge</Table.Th>
|
||||
<Table.Th ta="right">Qty</Table.Th>
|
||||
<Table.Th ta="right">Unit Rate</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lines.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Center py={28}>
|
||||
<Text fz={13} c="dimmed">
|
||||
No line items on this invoice.
|
||||
</Text>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{lines.map((line) => (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{titleCase(line.chargeType)}
|
||||
</Text>
|
||||
{line.description && (
|
||||
<Text fz={12} c="dimmed">
|
||||
{line.description}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{Number(line.quantity)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{formatCurrency(Number(line.unitRate), line.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||
{formatCurrency(Number(line.amount), line.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
537
apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
Normal file
537
apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CreditCard,
|
||||
Eye,
|
||||
FileStack,
|
||||
Inbox,
|
||||
Receipt,
|
||||
Search,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import {
|
||||
BORDER,
|
||||
GREEN,
|
||||
INK,
|
||||
MUTED,
|
||||
StatCard,
|
||||
} from "../contracts/contract-ui";
|
||||
import {
|
||||
billedTo,
|
||||
fmtDate,
|
||||
InvoiceStatusBadge,
|
||||
isPayable,
|
||||
PAYABLE_STATUSES,
|
||||
titleCase,
|
||||
} from "./invoice-ui";
|
||||
|
||||
const PAGE_SIZES = ["10", "25", "50"];
|
||||
|
||||
export default function InvoicesList() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.invoices.listMy.queryOptions(),
|
||||
);
|
||||
|
||||
const all = useMemo(() => data ?? [], [data]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const outstanding = all.filter((i) =>
|
||||
PAYABLE_STATUSES.includes(i.status),
|
||||
).length;
|
||||
const overdue = all.filter(
|
||||
(i) => i.status === Freight.InvoiceStatus.Overdue,
|
||||
).length;
|
||||
return { outstanding, overdue, total: all.length };
|
||||
}, [all]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return all.filter((inv) => {
|
||||
if (statusFilter && inv.status !== statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
inv.invoiceNumber.toLowerCase().includes(q) ||
|
||||
inv.source.toLowerCase().includes(q) ||
|
||||
inv.sourceId.toLowerCase().includes(q) ||
|
||||
billedTo(inv).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [all, query, statusFilter]);
|
||||
|
||||
const total = rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const clampedIndex = Math.min(pageIndex, pageCount - 1);
|
||||
const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
|
||||
const end = Math.min((clampedIndex + 1) * pageSize, total);
|
||||
const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
|
||||
|
||||
const resetPage = () => setPageIndex(0);
|
||||
const goToPage = (i: number) =>
|
||||
setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
|
||||
|
||||
const hasFilters = !!query || !!statusFilter;
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
Invoices
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
{/* Summary strip */}
|
||||
<Group gap="md" wrap="wrap" align="stretch">
|
||||
<StatCard
|
||||
label="Outstanding"
|
||||
hint="awaiting payment"
|
||||
value={stats.outstanding}
|
||||
icon={Wallet}
|
||||
color="edr-accent"
|
||||
/>
|
||||
<StatCard
|
||||
label="Overdue"
|
||||
hint="past due date"
|
||||
value={stats.overdue}
|
||||
icon={AlertTriangle}
|
||||
color="red"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total invoices"
|
||||
value={stats.total}
|
||||
icon={FileStack}
|
||||
color="violet"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Search + filters */}
|
||||
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
|
||||
<Group gap={10} wrap="wrap" align="center">
|
||||
<TextInput
|
||||
placeholder="Search by number, source or reference…"
|
||||
leftSection={<Search size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
styles={{ input: { height: 42 } }}
|
||||
style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any status"
|
||||
data={[
|
||||
{ value: Freight.InvoiceStatus.Pending, label: "Due" },
|
||||
{ value: Freight.InvoiceStatus.Overdue, label: "Overdue" },
|
||||
{ value: Freight.InvoiceStatus.Paid, label: "Paid" },
|
||||
{ value: Freight.InvoiceStatus.Draft, label: "Draft" },
|
||||
{ value: Freight.InvoiceStatus.Cancelled, label: "Cancelled" },
|
||||
{ value: Freight.InvoiceStatus.Refunded, label: "Refunded" },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 160 }}
|
||||
styles={{ input: { height: 42 } }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setStatusFilter(null);
|
||||
resetPage();
|
||||
}}
|
||||
styles={{ root: { fontWeight: 600 } }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Table */}
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||
>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
verticalSpacing={14}
|
||||
horizontalSpacing={20}
|
||||
highlightOnHover
|
||||
highlightOnHoverColor="#F4FBF8"
|
||||
styles={{
|
||||
th: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: MUTED,
|
||||
background: "#F8FAFC",
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
whiteSpace: "nowrap",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
tr: { transition: "background-color 120ms ease" },
|
||||
td: {
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Invoice</Table.Th>
|
||||
<Table.Th>Billed To</Table.Th>
|
||||
<Table.Th>Source</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Due</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Center py={48}>
|
||||
<Loader color="edr-green" size="sm" />
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
{!isLoading && isError && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Center py={48}>
|
||||
<Text fz={13} c="red">
|
||||
Failed to load invoices. Please try again.
|
||||
</Text>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pageRows.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||
<Text fz={13} c="dimmed">
|
||||
{hasFilters
|
||||
? "No invoices match your filters."
|
||||
: "No invoices yet."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
pageRows.map((inv) => {
|
||||
const payable = isPayable(inv.status);
|
||||
return (
|
||||
<Table.Tr
|
||||
key={inv.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/billing/${inv.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
background: "#E6F7EF",
|
||||
color: GREEN,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Receipt size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
{inv.invoiceNumber}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{titleCase(inv.type)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{billedTo(inv)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{titleCase(inv.source)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||
{formatCurrency(Number(inv.totalAmount), inv.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz={13}
|
||||
c={inv.issuedAt ? undefined : "dimmed"}
|
||||
style={{ color: inv.issuedAt ? INK : undefined }}
|
||||
>
|
||||
{fmtDate(inv.issuedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} style={{ color: INK }}>
|
||||
{fmtDate(inv.dueAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<InvoiceStatusBadge status={inv.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
radius="md"
|
||||
h={34}
|
||||
variant={payable ? "filled" : "light"}
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
payable ? (
|
||||
<CreditCard size={15} />
|
||||
) : (
|
||||
<Eye size={15} />
|
||||
)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/billing/${inv.id}`);
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
paddingInline: 14,
|
||||
whiteSpace: "nowrap",
|
||||
boxShadow: payable
|
||||
? "0 1px 2px rgba(14,163,113,0.25)"
|
||||
: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{payable ? "Pay" : "View"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Pagination footer */}
|
||||
{!isLoading && !isError && total > 0 && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
px={20}
|
||||
py={14}
|
||||
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
|
||||
>
|
||||
<Group gap={10} align="center">
|
||||
<Text fz={13} c="dimmed">
|
||||
Rows
|
||||
</Text>
|
||||
<Select
|
||||
data={PAGE_SIZES}
|
||||
value={String(pageSize)}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setPageSize(Number(v));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
radius="md"
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 76 }}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Text fz={13} c="dimmed">
|
||||
{start}–{end} of {total}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} align="center">
|
||||
<PagerButton
|
||||
icon={<ChevronLeft size={16} />}
|
||||
disabled={clampedIndex === 0}
|
||||
onClick={() => goToPage(clampedIndex - 1)}
|
||||
ariaLabel="Previous page"
|
||||
/>
|
||||
{pageNumbers(clampedIndex, pageCount).map((p, i) =>
|
||||
p === "…" ? (
|
||||
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
|
||||
…
|
||||
</Text>
|
||||
) : (
|
||||
<PageChip
|
||||
key={p}
|
||||
page={p}
|
||||
active={p === clampedIndex}
|
||||
onClick={() => goToPage(p)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<PagerButton
|
||||
icon={<ChevronRight size={16} />}
|
||||
disabled={clampedIndex >= pageCount - 1}
|
||||
onClick={() => goToPage(clampedIndex + 1)}
|
||||
ariaLabel="Next page"
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
|
||||
function pageNumbers(active: number, count: number): (number | "…")[] {
|
||||
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
|
||||
const out: (number | "…")[] = [0];
|
||||
const lo = Math.max(1, active - 1);
|
||||
const hi = Math.min(count - 2, active + 1);
|
||||
if (lo > 1) out.push("…");
|
||||
for (let i = lo; i <= hi; i++) out.push(i);
|
||||
if (hi < count - 2) out.push("…");
|
||||
out.push(count - 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
function PageChip({
|
||||
page,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
page: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
padding: "0 8px",
|
||||
borderRadius: 9,
|
||||
border: `1px solid ${active ? GREEN : BORDER}`,
|
||||
background: active ? GREEN : "#FFFFFF",
|
||||
color: active ? "#FFFFFF" : INK,
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 700 : 600,
|
||||
cursor: "pointer",
|
||||
transition: "all 120ms ease",
|
||||
}}
|
||||
>
|
||||
{page + 1}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PagerButton({
|
||||
icon,
|
||||
disabled,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 9,
|
||||
border: `1px solid ${BORDER}`,
|
||||
background: "#FFFFFF",
|
||||
color: disabled ? "#C2CCD6" : INK,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "all 120ms ease",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Calendar, DollarSign, Hash } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { customers } from "../customers/customers.mock";
|
||||
import { bookings } from "../bookings/bookings.mock";
|
||||
import type { Currency, InvoiceStatus } from "./invoices.mock";
|
||||
|
||||
export interface InvoiceFormData {
|
||||
number?: string;
|
||||
customerId?: number;
|
||||
bookingReference?: string;
|
||||
amount?: number;
|
||||
currency?: Currency;
|
||||
status?: InvoiceStatus;
|
||||
issueDate?: string;
|
||||
dueDate?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface NewInvoicePageProps {
|
||||
mode?: "create" | "edit";
|
||||
invoice?: InvoiceFormData;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
||||
|
||||
export default function NewInvoicePage({
|
||||
mode = "create",
|
||||
invoice,
|
||||
children,
|
||||
}: NewInvoicePageProps = {}) {
|
||||
const isEdit = mode === "edit";
|
||||
const title = isEdit ? "Edit Invoice" : "New Invoice";
|
||||
const description = isEdit
|
||||
? "Update invoice details."
|
||||
: "Create a new invoice for a customer booking.";
|
||||
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
{/* Invoice Number */}
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice Number *</Label>
|
||||
<div className="relative">
|
||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
defaultValue={invoice?.number ?? ""}
|
||||
placeholder="e.g. INV-2026-0001"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<select
|
||||
defaultValue={invoice?.status ?? "Draft"}
|
||||
className={selectClass}
|
||||
>
|
||||
<option>Draft</option>
|
||||
<option>Sent</option>
|
||||
<option>Paid</option>
|
||||
<option>Overdue</option>
|
||||
<option>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Customer */}
|
||||
<div className="space-y-2">
|
||||
<Label>Customer *</Label>
|
||||
<select
|
||||
defaultValue={invoice?.customerId ?? ""}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select customer
|
||||
</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.company}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Booking */}
|
||||
<div className="space-y-2">
|
||||
<Label>Booking Reference</Label>
|
||||
<select
|
||||
defaultValue={invoice?.bookingReference ?? ""}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">No linked booking</option>
|
||||
{bookings.map((b) => (
|
||||
<option key={b.id} value={b.reference}>
|
||||
{b.reference} — {b.customer}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="space-y-2">
|
||||
<Label>Amount *</Label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
defaultValue={invoice?.amount ?? 0}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currency */}
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<select
|
||||
defaultValue={invoice?.currency ?? "USD"}
|
||||
className={selectClass}
|
||||
>
|
||||
<option>USD</option>
|
||||
<option>ETB</option>
|
||||
<option>DJF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Issue Date */}
|
||||
<div className="space-y-2">
|
||||
<Label>Issue Date *</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
type="date"
|
||||
defaultValue={invoice?.issueDate ?? ""}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div className="space-y-2">
|
||||
<Label>Due Date *</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
type="date"
|
||||
defaultValue={invoice?.dueDate ?? ""}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
defaultValue={invoice?.notes ?? ""}
|
||||
placeholder="Payment terms, references, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
60
apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
Normal file
60
apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box } from "@mantine/core";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import type { PortalInvoice } from "@/services/invoices.service";
|
||||
|
||||
/** Statuses a customer can still pay. */
|
||||
export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
export const isPayable = (status: Freight.InvoiceStatus) =>
|
||||
PAYABLE_STATUSES.includes(status);
|
||||
|
||||
const STATUS_STYLE: Record<
|
||||
Freight.InvoiceStatus,
|
||||
{ label: string; bg: string; fg: string }
|
||||
> = {
|
||||
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
|
||||
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
|
||||
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
||||
const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
background: s.bg,
|
||||
color: s.fg,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const titleCase = (v: string) =>
|
||||
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
|
||||
|
||||
/** Best label for who an invoice is billed to (profile ref → profile type → company). */
|
||||
export function billedTo(inv: PortalInvoice): string {
|
||||
const profile = inv.companyProfile;
|
||||
if (profile?.reference) return profile.reference;
|
||||
if (profile?.type) return titleCase(profile.type);
|
||||
return inv.company?.name ?? "—";
|
||||
}
|
||||
|
||||
export const fmtDate = (v: string | null | undefined) =>
|
||||
v ? new Date(v).toLocaleDateString() : "—";
|
||||
@@ -1,88 +0,0 @@
|
||||
import { customers } from "../customers/customers.mock";
|
||||
import { bookings } from "../bookings/bookings.mock";
|
||||
|
||||
export type InvoiceStatus =
|
||||
| "Draft"
|
||||
| "Sent"
|
||||
| "Paid"
|
||||
| "Overdue"
|
||||
| "Cancelled";
|
||||
|
||||
export type Currency = "USD" | "ETB" | "DJF";
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
number: string;
|
||||
customerId: number;
|
||||
customer: string;
|
||||
bookingReference: string;
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
status: InvoiceStatus;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
paidDate: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const statuses: InvoiceStatus[] = [
|
||||
"Draft",
|
||||
"Sent",
|
||||
"Paid",
|
||||
"Overdue",
|
||||
"Cancelled",
|
||||
];
|
||||
const currencies: Currency[] = ["USD", "ETB", "DJF"];
|
||||
|
||||
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
|
||||
const customer = customers[i % customers.length] as (typeof customers)[number];
|
||||
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
||||
const id = i + 1;
|
||||
const issue = new Date(2026, 3, 1 + (i % 28));
|
||||
const due = new Date(issue);
|
||||
due.setDate(due.getDate() + 30);
|
||||
const status = statuses[i % statuses.length] as InvoiceStatus;
|
||||
const currency = currencies[i % currencies.length] as Currency;
|
||||
const baseAmount = 5000 + (i * 1234) % 25000;
|
||||
return {
|
||||
id,
|
||||
number: `INV-2026-${String(id).padStart(4, "0")}`,
|
||||
customerId: customer.id,
|
||||
customer: customer.company,
|
||||
bookingReference: booking.reference,
|
||||
amount: Math.round(baseAmount * 100) / 100,
|
||||
currency,
|
||||
status,
|
||||
issueDate: issue.toISOString().slice(0, 10),
|
||||
dueDate: due.toISOString().slice(0, 10),
|
||||
paidDate:
|
||||
status === "Paid"
|
||||
? new Date(due.getTime() - 86400000 * (i % 7))
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
: null,
|
||||
notes:
|
||||
i % 3 === 0
|
||||
? "Net 30 payment terms."
|
||||
: i % 3 === 1
|
||||
? "Bank transfer preferred."
|
||||
: "Payment due upon receipt.",
|
||||
};
|
||||
});
|
||||
|
||||
export function getInvoiceById(id: number | string): Invoice | undefined {
|
||||
const numericId = typeof id === "string" ? Number(id) : id;
|
||||
return invoices.find((inv) => inv.id === numericId);
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency: Currency): string {
|
||||
const symbols: Record<Currency, string> = {
|
||||
USD: "$",
|
||||
ETB: "Br",
|
||||
DJF: "DJF",
|
||||
};
|
||||
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
IntentStatus,
|
||||
} from "./payments.service";
|
||||
import { consignmentsService } from "./consignments.service";
|
||||
import {
|
||||
invoicesService,
|
||||
PortalInvoice,
|
||||
PortalInvoiceDetail,
|
||||
PayInvoicePayload,
|
||||
} from "./invoices.service";
|
||||
import { trackingService } from "./tracking.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
@@ -597,4 +603,23 @@ export const api = {
|
||||
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
||||
),
|
||||
},
|
||||
|
||||
invoices: {
|
||||
listMy: endpoint<void, PortalInvoice[]>(
|
||||
"invoices",
|
||||
"listMy",
|
||||
invoicesService.listMy,
|
||||
),
|
||||
|
||||
get: endpoint<{ id: string }, PortalInvoiceDetail>(
|
||||
"invoices",
|
||||
"get",
|
||||
({ id }) => invoicesService.get(id),
|
||||
),
|
||||
|
||||
pay: endpoint<
|
||||
{ id: string; payload?: PayInvoicePayload },
|
||||
InitiateResponse
|
||||
>("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
|
||||
},
|
||||
};
|
||||
|
||||
49
apps/edr-freight-web/portal/src/services/invoices.service.ts
Normal file
49
apps/edr-freight-web/portal/src/services/invoices.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
import type { InitiateResponse } from "./payments.service";
|
||||
|
||||
const B = URL_CONSTANTS.BILLING;
|
||||
|
||||
/** A customer-facing invoice row, as returned by `GET /billing/my-invoices`. */
|
||||
export type PortalInvoice = Freight.IInvoice;
|
||||
|
||||
/** An invoice plus its line items, as returned by `GET /billing/my-invoices/:id`. */
|
||||
export type PortalInvoiceDetail = Freight.IInvoice & {
|
||||
lines: Freight.IInvoiceLine[];
|
||||
};
|
||||
|
||||
export interface PayInvoicePayload {
|
||||
method?: string;
|
||||
platform?: "web" | "mobile";
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export const invoicesService = {
|
||||
/** Every invoice billed to the signed-in customer's company, newest first. */
|
||||
listMy: async (): Promise<PortalInvoice[]> => {
|
||||
const { data } = await client.get(B.MY_INVOICES);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** One of the customer's invoices, with its line items. */
|
||||
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Initiate gateway payment for an open invoice; returns the client action. */
|
||||
pay: async (
|
||||
id: string,
|
||||
payload: PayInvoicePayload = {},
|
||||
): Promise<InitiateResponse> => {
|
||||
const { data } = await client.post(B.PAY_INVOICE(id), {
|
||||
platform: "web",
|
||||
...payload,
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Migration: Add Configurable Fare Management System
|
||||
|
||||
-- Main fare configuration table
|
||||
CREATE TABLE "fare_configurations" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_configurations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
|
||||
);
|
||||
|
||||
-- Rate structure by nationality and coach/position
|
||||
CREATE TABLE "fare_rate_rules" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
|
||||
@@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" (
|
||||
);
|
||||
|
||||
-- Configurable fare components (insurance, premiums, service charges, taxes)
|
||||
CREATE TABLE "fare_components" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_components" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
|
||||
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
|
||||
);
|
||||
|
||||
-- Age-based pricing rules
|
||||
CREATE TABLE "age_pricing_rules" (
|
||||
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"rule_name" TEXT NOT NULL,
|
||||
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
|
||||
);
|
||||
|
||||
-- Audit trail for configuration changes
|
||||
CREATE TABLE "fare_configuration_audit" (
|
||||
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
|
||||
@@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" (
|
||||
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Foreign key constraints
|
||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-- Foreign key constraints (idempotent)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
|
||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||
-- Indexes for performance (idempotent)
|
||||
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||
|
||||
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||
|
||||
-- Add legacy mode flag to existing fare tables for gradual migration
|
||||
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
||||
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
||||
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
|
||||
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||
|
||||
-- Add feature flag support
|
||||
CREATE TABLE "system_features" (
|
||||
CREATE TABLE IF NOT EXISTS "system_features" (
|
||||
"id" TEXT NOT NULL,
|
||||
"feature_name" TEXT NOT NULL UNIQUE,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -114,4 +124,4 @@ CREATE TABLE "system_features" (
|
||||
|
||||
-- Insert the configurable fares feature flag
|
||||
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
|
||||
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);
|
||||
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);
|
||||
|
||||
@@ -483,8 +483,8 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
@@ -660,8 +660,8 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
@@ -854,8 +854,8 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
@@ -1060,7 +1060,7 @@ export class BookingsService {
|
||||
loyaltyRedemptionPoints?: number
|
||||
) {
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
|
||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
@@ -1076,8 +1076,8 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
return {
|
||||
baseFareMinor,
|
||||
@@ -1103,6 +1103,8 @@ export class BookingsService {
|
||||
nationality?: string,
|
||||
originStopSeq?: number,
|
||||
destStopSeq?: number,
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
@@ -1149,13 +1151,13 @@ export class BookingsService {
|
||||
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
|
||||
if (bestMatch) return bestMatch.baseFareMinor;
|
||||
|
||||
// 3. FareEngine — distance × rate-per-km from the schedule's route
|
||||
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||||
if (schedule?.routeId) {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
originStationId: originStationId ?? schedule.originStationId,
|
||||
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
|
||||
/** Booking cutoff: reject new bookings within this many ms of departure. */
|
||||
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
@@ -74,6 +77,10 @@ export class GuestBookingService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||
@@ -142,7 +149,9 @@ export class GuestBookingService {
|
||||
dto.seatClassId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
primaryNationality
|
||||
primaryNationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
@@ -160,8 +169,8 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
@@ -293,6 +302,10 @@ export class GuestBookingService {
|
||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||||
|
||||
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||
@@ -350,8 +363,8 @@ export class GuestBookingService {
|
||||
const primaryNationality = passengersData[0]?.nationality;
|
||||
|
||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||
]);
|
||||
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
@@ -369,8 +382,8 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -505,6 +518,10 @@ export class GuestBookingService {
|
||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
||||
|
||||
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
@@ -551,11 +568,11 @@ export class GuestBookingService {
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId,
|
||||
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
|
||||
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
|
||||
primaryNationality),
|
||||
primaryNationality, dto.originStationId, dto.transitStationId),
|
||||
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
|
||||
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
|
||||
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
|
||||
primaryNationality),
|
||||
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
|
||||
]);
|
||||
|
||||
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
|
||||
@@ -569,8 +586,8 @@ export class GuestBookingService {
|
||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBase - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -702,6 +719,10 @@ export class GuestBookingService {
|
||||
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
||||
|
||||
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||
}
|
||||
|
||||
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||
@@ -750,10 +771,10 @@ export class GuestBookingService {
|
||||
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
||||
|
||||
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
|
||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
|
||||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
|
||||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
|
||||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
|
||||
]);
|
||||
|
||||
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
|
||||
@@ -951,17 +972,28 @@ export class GuestBookingService {
|
||||
segmentRoute?: string,
|
||||
fullRoute?: string,
|
||||
nationality?: string,
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// 1. FareRule table — explicit override rules
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
|
||||
const [candidates, seatClass] = await Promise.all([
|
||||
this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
}),
|
||||
this.prisma.seatClass.findUnique({
|
||||
where: { id: seatClassId },
|
||||
select: { premiumMinor: true, insuranceFeeMinor: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const premiumMinor = seatClass?.premiumMinor ?? 0;
|
||||
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
|
||||
|
||||
const priorities = [
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||
@@ -982,10 +1014,11 @@ export class GuestBookingService {
|
||||
const match = candidates.find(
|
||||
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
|
||||
);
|
||||
if (match) return match.baseFareMinor;
|
||||
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
|
||||
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
|
||||
}
|
||||
|
||||
// 2. FareEngine — distance × rate-per-km from the schedule's route
|
||||
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
@@ -995,12 +1028,15 @@ export class GuestBookingService {
|
||||
try {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
// Use the booking's boarding/alighting stations so the distance reflects the
|
||||
// passenger's actual segment, not the full schedule route.
|
||||
originStationId: originStationId ?? schedule.originStationId,
|
||||
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
|
||||
return fare.farePerPassengerMinor;
|
||||
} catch {
|
||||
// FareEngine throws if distanceKm is missing; fall through to error
|
||||
}
|
||||
|
||||
@@ -284,13 +284,16 @@ export class FareEngineService {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
return fareRules.map(rule => {
|
||||
const seatClassId = rule.seatClassId;
|
||||
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
|
||||
const totalMinor = rule.baseFareMinor + taxMinor;
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
taxMinor,
|
||||
totalMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SupportedPaymentMethodDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
BookingAmountResponseDto,
|
||||
} from "./payments.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -139,16 +140,33 @@ export class PaymentsController {
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
|
||||
"Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.",
|
||||
})
|
||||
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
|
||||
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(
|
||||
@Query("currency") currency?: string,
|
||||
@Query("region") region?: PaymentRegionEnum,
|
||||
) {
|
||||
return this.service.getSupportedPaymentMethods(region, currency);
|
||||
return this.service.getSupportedPaymentMethods(region);
|
||||
}
|
||||
|
||||
@Get("booking-amount")
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get booking amount in a specific currency",
|
||||
description:
|
||||
"Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
|
||||
"If currency is ETB the stored amount is returned as-is (no conversion). " +
|
||||
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" })
|
||||
@ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" })
|
||||
@ApiOkResponse({ type: BookingAmountResponseDto })
|
||||
getBookingAmount(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("currency") currency: string,
|
||||
) {
|
||||
return this.service.getBookingAmountByCurrency(bookingId, currency);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
|
||||
@@ -136,3 +136,9 @@ export class IntentStatusDto {
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
@ApiPropertyOptional() failureMessage?: string;
|
||||
}
|
||||
|
||||
export class BookingAmountResponseDto {
|
||||
@ApiProperty({ example: 'booking-uuid' }) booking_id: string;
|
||||
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
|
||||
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
@@ -490,12 +490,41 @@ export class PaymentsService {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(currency ? { currency: currency.toUpperCase() } : {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingAmountByCurrency(
|
||||
bookingId: string,
|
||||
currency: string,
|
||||
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, totalMinor: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const requestedCurrency = currency.toUpperCase();
|
||||
const amountInETB = booking.totalMinor / 100;
|
||||
|
||||
if (requestedCurrency === 'ETB') {
|
||||
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
||||
}
|
||||
|
||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
if (!exchangeRate) {
|
||||
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
|
||||
}
|
||||
|
||||
const rate = Number(exchangeRate.rate);
|
||||
const converted = parseFloat((amountInETB * rate).toFixed(2));
|
||||
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
|
||||
* ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
|
||||
|
||||
@@ -474,8 +474,8 @@ export class SearchService {
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
|
||||
@@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
|
||||
/** Minutes before departure at which each action fires. */
|
||||
const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS
|
||||
const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking
|
||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||
const MAX_PAYMENT_HOURS = 2;
|
||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||
const CUTOFF_MINUTES = 30;
|
||||
|
||||
/** Half-width of the reminder detection window (cron runs every 2 min). */
|
||||
const REMINDER_WINDOW_MINUTES = 2;
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
*/
|
||||
function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
|
||||
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
|
||||
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
|
||||
}
|
||||
|
||||
function fmtTime(d: Date): string {
|
||||
return d.toLocaleTimeString('en-GB', {
|
||||
@@ -28,66 +35,72 @@ export class TasksService {
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 2 min: advance TrainSchedule statuses (departure / arrival).
|
||||
// Every 1 min: advance TrainSchedule statuses.
|
||||
//
|
||||
// SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings)
|
||||
// BOARDING → EN_ROUTE at actual departure
|
||||
// EN_ROUTE → ARRIVED at arrival time
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/2 * * * *')
|
||||
@Cron('*/1 * * * *')
|
||||
async syncScheduleStatuses() {
|
||||
const now = new Date();
|
||||
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
const [departed, arrived] = await Promise.all([
|
||||
const [boarding, departed, arrived] = await Promise.all([
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: now } },
|
||||
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
|
||||
data: { status: 'BOARDING' },
|
||||
}),
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: 'BOARDING', departureAt: { lte: now } },
|
||||
data: { status: 'EN_ROUTE' },
|
||||
}),
|
||||
this.prisma.trainSchedule.updateMany({
|
||||
where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } },
|
||||
where: { status: 'EN_ROUTE', arrivalAt: { lte: now } },
|
||||
data: { status: 'ARRIVED' },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (departed.count > 0 || arrived.count > 0) {
|
||||
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
|
||||
this.logger.log(
|
||||
`Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 2 min: payment deadline enforcement.
|
||||
// Every 1 min: payment deadline enforcement.
|
||||
//
|
||||
// • 3 h before departure → send one SMS reminder to complete payment.
|
||||
// • 2 h before departure → cancel booking if payment is still pending
|
||||
// and notify the passenger by SMS.
|
||||
// Reminder — sent once at the midpoint of the booking's payment window:
|
||||
// reminder_at = booking_time + total_window / 2
|
||||
//
|
||||
// Example: train departs 08:00
|
||||
// 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled")
|
||||
// 06:00 → booking auto-cancelled, cancellation SMS sent
|
||||
// Cancel — when now ≥ payment_deadline
|
||||
// payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
//
|
||||
// Examples (departure 10:00, cutoff 9:30):
|
||||
// Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45
|
||||
// Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/2 * * * *')
|
||||
@Cron('*/1 * * * *')
|
||||
async enforcePaymentDeadlines() {
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all([
|
||||
this.sendPaymentReminders(now),
|
||||
this.cancelExpiredPendingBookings(now),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 3-hour reminder ───────────────────────────────────────────────────────
|
||||
// ── Send reminder at the midpoint of each booking's payment window ────────
|
||||
private async sendPaymentReminders(now: Date) {
|
||||
// Narrow 4-minute window (±2 min around the 3-hour mark) so each booking
|
||||
// is caught by exactly one cron tick and paymentReminderSentAt guards re-sends.
|
||||
const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000;
|
||||
const reminderMs = REMINDER_MINUTES * 60 * 1000;
|
||||
|
||||
const windowStart = new Date(now.getTime() + reminderMs - windowMs);
|
||||
const windowEnd = new Date(now.getTime() + reminderMs + windowMs);
|
||||
// Only look at bookings created within the last 3 h with a future departure.
|
||||
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentReminderSentAt: null,
|
||||
schedule: { departureAt: { gte: windowStart, lte: windowEnd } },
|
||||
createdAt: { gte: threeHoursAgo },
|
||||
schedule: { departureAt: { gte: now } },
|
||||
} as any,
|
||||
include: {
|
||||
schedule: {
|
||||
@@ -101,15 +114,28 @@ export class TasksService {
|
||||
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000);
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
|
||||
|
||||
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
|
||||
if (totalWindowMs < 2 * 60 * 1000) continue;
|
||||
|
||||
// Remind once, at the midpoint of the total payment window
|
||||
const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2);
|
||||
if (now < reminderAt) continue;
|
||||
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime());
|
||||
const remainingMin = Math.round(remainingMs / 60_000);
|
||||
|
||||
const message =
|
||||
`EDR: Your booking ${booking.bookingRef} ` +
|
||||
`(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` +
|
||||
`Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`;
|
||||
`Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` +
|
||||
`or your booking will be cancelled.`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
@@ -121,7 +147,8 @@ export class TasksService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`,
|
||||
`Payment reminder sent: ${booking.bookingRef} ` +
|
||||
`(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -131,14 +158,22 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2-hour auto-cancel ────────────────────────────────────────────────────
|
||||
// ── Cancel bookings whose payment deadline has passed ─────────────────────
|
||||
private async cancelExpiredPendingBookings(now: Date) {
|
||||
const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h
|
||||
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
|
||||
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
|
||||
|
||||
// payment_deadline = MIN(createdAt + 2h, departureAt - 30min)
|
||||
// Deadline is reached when either branch of the MIN is in the past:
|
||||
// (a) createdAt ≤ now - 2h → 2-hour max window elapsed
|
||||
// (b) departureAt ≤ now + 30min → departure within 30 min
|
||||
const expiredBookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
schedule: { departureAt: { lte: cutoff } },
|
||||
OR: [
|
||||
{ createdAt: { lte: twoHoursAgo } },
|
||||
{ schedule: { departureAt: { lte: departureCutoff } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
schedule: {
|
||||
@@ -151,8 +186,16 @@ export class TasksService {
|
||||
},
|
||||
});
|
||||
|
||||
let cancelledCount = 0;
|
||||
|
||||
for (const booking of expiredBookings) {
|
||||
try {
|
||||
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
|
||||
const createdAt = booking.createdAt as Date;
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const paymentDeadline = computePaymentDeadline(createdAt, dep);
|
||||
if (now < paymentDeadline) continue;
|
||||
|
||||
// 1. Release held seats (Journey rows are the occupancy source of truth)
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
|
||||
|
||||
@@ -161,12 +204,12 @@ export class TasksService {
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
cancelledBy: 'SYSTEM',
|
||||
reason: 'Payment not completed before departure deadline',
|
||||
reason: 'Payment not completed before deadline',
|
||||
refundAmount: 0,
|
||||
refundMethod: booking.paymentIntent?.method ?? 'NONE',
|
||||
refundStatus: 'NOT_APPLICABLE',
|
||||
},
|
||||
}).catch(() => null); // booking may already have a cancellation record
|
||||
}).catch(() => null);
|
||||
|
||||
// 3. Mark cancelled
|
||||
await this.prisma.booking.update({
|
||||
@@ -175,22 +218,20 @@ export class TasksService {
|
||||
});
|
||||
|
||||
// 4. Notify passenger
|
||||
const dep = booking.schedule.departureAt as Date;
|
||||
const origin = booking.schedule.originStation?.name ?? '';
|
||||
const dest = booking.schedule.destinationStation?.name ?? '';
|
||||
|
||||
const message =
|
||||
`EDR: Your booking ${booking.bookingRef} ` +
|
||||
`(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` +
|
||||
`because payment was not completed before the deadline.`;
|
||||
`because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`;
|
||||
|
||||
if (booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`,
|
||||
);
|
||||
this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
|
||||
cancelledCount++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
@@ -198,8 +239,8 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredBookings.length > 0) {
|
||||
this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`);
|
||||
if (cancelledCount > 0) {
|
||||
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type BookingWithTicket = {
|
||||
@@ -77,54 +76,71 @@ export default function ConfirmationPage() {
|
||||
};
|
||||
|
||||
const handleDownloadVoucher = async () => {
|
||||
if (!_booking || !pnr) {
|
||||
if (!pnr) {
|
||||
alert('Booking data not available. Please try again.');
|
||||
return;
|
||||
}
|
||||
if (!passengers.length) {
|
||||
alert('No passenger data found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingVoucher(true);
|
||||
try {
|
||||
console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers });
|
||||
|
||||
const { generateVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
|
||||
const voucherData = {
|
||||
bookingRef: pnr,
|
||||
status: _booking.status || 'CONFIRMED',
|
||||
passengers: passengers.map(p => ({
|
||||
fullName: p.name,
|
||||
category: 'ADULT',
|
||||
seat: p.seatNumber ? {
|
||||
number: p.seatNumber,
|
||||
coach: 'N/A',
|
||||
seatClass: selectedSchedule?.selectedSeatClassName || 'Standard',
|
||||
} : undefined,
|
||||
})),
|
||||
schedule: {
|
||||
trainNumber: selectedSchedule?.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: {
|
||||
name: selectedSchedule?.origin || 'Origin',
|
||||
code: 'ORG',
|
||||
city: selectedSchedule?.origin || 'Origin',
|
||||
},
|
||||
destination: {
|
||||
name: selectedSchedule?.destination || 'Destination',
|
||||
code: 'DST',
|
||||
city: selectedSchedule?.destination || 'Destination',
|
||||
},
|
||||
departureAt: selectedSchedule?.departureTime || new Date().toISOString(),
|
||||
arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(),
|
||||
},
|
||||
totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0),
|
||||
currency: 'ETB',
|
||||
bookingType: 'ONE_WAY',
|
||||
createdAt: new Date().toISOString(),
|
||||
const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher');
|
||||
|
||||
const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
const totalFare = _booking?.totalMinor
|
||||
|| passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0);
|
||||
const farePerPassenger = Math.round(totalFare / passengers.length);
|
||||
const createdAt = _booking?.createdAt || new Date().toISOString();
|
||||
const status = _booking?.status || 'CONFIRMED';
|
||||
|
||||
const outbound = {
|
||||
trainNumber: activeSchedule?.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' },
|
||||
destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' },
|
||||
departureAt: activeSchedule?.departureTime || new Date().toISOString(),
|
||||
arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(),
|
||||
seatClass: activeSchedule?.selectedSeatClassName,
|
||||
};
|
||||
|
||||
console.log('📄 Voucher data prepared:', voucherData);
|
||||
await generateVoucherPDF(voucherData);
|
||||
console.log('✅ Voucher generated successfully');
|
||||
const inbound = inboundSchedule ? {
|
||||
trainNumber: inboundSchedule.trainNumber || 'N/A',
|
||||
trainName: 'EDR Express',
|
||||
origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin },
|
||||
destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination },
|
||||
departureAt: inboundSchedule.departureTime || new Date().toISOString(),
|
||||
arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(),
|
||||
seatClass: inboundSchedule.selectedSeatClassName,
|
||||
} : undefined;
|
||||
|
||||
for (let i = 0; i < passengers.length; i++) {
|
||||
const p = passengers[i];
|
||||
const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`;
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: pnr,
|
||||
ticketNumber,
|
||||
passengerName: p.name || `Passenger ${i + 1}`,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
nationality: p.nationality,
|
||||
seatNumber: p.seatNumber,
|
||||
outboundSeatNumber: (p as any).outboundSeatNumber,
|
||||
inboundSeatNumber: (p as any).inboundSeatNumber,
|
||||
status,
|
||||
outboundSchedule: outbound,
|
||||
inboundSchedule: inbound,
|
||||
isRoundTrip,
|
||||
fareMinor: farePerPassenger,
|
||||
currency: 'ETB',
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// brief pause between downloads so browsers don't block them
|
||||
if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate voucher:', error);
|
||||
alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
@@ -191,17 +207,9 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Summary with QR Code */}
|
||||
{/* Trip Details */}
|
||||
<div className="card mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* QR Code Section */}
|
||||
<div className="flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 rounded-lg p-6 md:w-48 flex-shrink-0">
|
||||
<QRCodeSVG value={pnr} size={160} level="H" includeMargin={true} />
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2 text-center font-semibold">Scan at gate</p>
|
||||
</div>
|
||||
|
||||
{/* Trip Details */}
|
||||
<div className="flex-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<Train className="w-6 h-6 text-primary dark:text-primary-400" />
|
||||
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -332,12 +332,135 @@ function DobPickerModal({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── phone validation ─────────────────────────────────────────────────────────
|
||||
|
||||
type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
||||
|
||||
const PHONE_PRESETS: Record<PhoneNat, { flag: string; code: string; example: string; hint: string }> = {
|
||||
ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' },
|
||||
DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' },
|
||||
OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' },
|
||||
};
|
||||
|
||||
function getPhoneNat(nationality: string): PhoneNat {
|
||||
const n = (nationality || '').toUpperCase();
|
||||
if (n === 'ETHIOPIAN') return 'ETHIOPIAN';
|
||||
if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN';
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
function validatePhone(phone: string, nationality: string): string | null {
|
||||
const normalized = (phone || '').replace(/[\s\-().]/g, '');
|
||||
if (!normalized) return 'Phone number is required';
|
||||
const nat = getPhoneNat(nationality);
|
||||
if (nat === 'ETHIOPIAN') {
|
||||
if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null;
|
||||
return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)';
|
||||
}
|
||||
if (nat === 'DJIBOUTIAN') {
|
||||
if (/^\+253\d{8}$/.test(normalized)) return null;
|
||||
return 'Invalid Djiboutian phone number (e.g., +25377123456)';
|
||||
}
|
||||
if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null;
|
||||
return 'Invalid international phone number (e.g., +14155552671)';
|
||||
}
|
||||
|
||||
function stripPhonePrefix(stored: string, nat: PhoneNat): string {
|
||||
const code = PHONE_PRESETS[nat].code;
|
||||
if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length);
|
||||
if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1);
|
||||
return stored;
|
||||
}
|
||||
|
||||
function buildFullNumber(localInput: string, nat: PhoneNat): string {
|
||||
const stripped = localInput.replace(/[\s\-().]/g, '');
|
||||
if (!stripped) return stripped;
|
||||
if (nat === 'ETHIOPIAN') {
|
||||
if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped;
|
||||
return '+251' + stripped;
|
||||
}
|
||||
if (nat === 'DJIBOUTIAN') {
|
||||
if (stripped.startsWith('+')) return stripped;
|
||||
return '+253' + stripped;
|
||||
}
|
||||
return stripped.startsWith('+') ? stripped : '+' + stripped;
|
||||
}
|
||||
|
||||
function PhoneInput({
|
||||
nationality,
|
||||
storedValue,
|
||||
onInterimChange,
|
||||
onNormalized,
|
||||
error,
|
||||
}: {
|
||||
nationality: string;
|
||||
storedValue: string;
|
||||
onInterimChange: (full: string) => void;
|
||||
onNormalized: (full: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const nat = getPhoneNat(nationality);
|
||||
const preset = PHONE_PRESETS[nat];
|
||||
const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat));
|
||||
const prevStoredRef = useRef(storedValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (storedValue !== prevStoredRef.current) {
|
||||
prevStoredRef.current = storedValue;
|
||||
setLocalInput(stripPhonePrefix(storedValue || '', nat));
|
||||
}
|
||||
}, [storedValue, nat]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
setLocalInput(raw);
|
||||
onInterimChange(buildFullNumber(raw, nat));
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const full = buildFullNumber(localInput, nat);
|
||||
setLocalInput(stripPhonePrefix(full, nat));
|
||||
onNormalized(full);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`flex rounded-lg overflow-hidden border transition-colors focus-within:ring-1 ${
|
||||
error
|
||||
? 'border-red-500 focus-within:ring-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600 focus-within:border-primary focus-within:ring-primary'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2.5 bg-gray-50 dark:bg-gray-800 border-r border-gray-300 dark:border-gray-600 select-none flex-shrink-0">
|
||||
<span className="text-sm leading-none">{preset.flag}</span>
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{preset.code}</span>
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
value={localInput}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={preset.example}
|
||||
autoComplete="tel"
|
||||
className="flex-1 px-3 py-2.5 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white outline-none min-w-0"
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-red-500 text-xs mt-1">{error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Format: {preset.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── passenger zod schema ──────────────────────────────────────────────────────
|
||||
|
||||
const passengerSchema = z.object({
|
||||
name: z.string().min(2, 'Full name is required (min 2 characters)'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
gender: z.string().min(1, 'Gender is required'),
|
||||
nationality: z.string().min(1, 'Nationality is required'),
|
||||
phone: z.string().min(1, 'Phone number is required'),
|
||||
phone: z.string(),
|
||||
email: z.string().optional(),
|
||||
nationalId: z.string().optional(),
|
||||
passportNumber: z.string().optional(),
|
||||
@@ -358,6 +481,10 @@ const passengerSchema = z.object({
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
|
||||
}
|
||||
}
|
||||
const phoneError = validatePhone(data.phone, data.nationality);
|
||||
if (phoneError) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] });
|
||||
}
|
||||
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||
if (isNonEthiopian) {
|
||||
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||
@@ -769,14 +896,13 @@ export default function PassengersPage() {
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||
placeholder="+251911234567"
|
||||
<PhoneInput
|
||||
nationality={passengers[index]?.nationality || 'ETHIOPIAN'}
|
||||
storedValue={passengers[index]?.phone || ''}
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
/>
|
||||
{errors.passengers?.[index]?.phone && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
@@ -850,14 +976,13 @@ export default function PassengersPage() {
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||
<input
|
||||
{...register(`passengers.${index}.phone`)}
|
||||
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||
placeholder="+254712345678"
|
||||
<PhoneInput
|
||||
nationality={passengers[index]?.nationality || 'OTHER'}
|
||||
storedValue={passengers[index]?.phone || ''}
|
||||
onInterimChange={(v) => setValue(`passengers.${index}.phone`, v)}
|
||||
onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })}
|
||||
error={errors.passengers?.[index]?.phone?.message}
|
||||
/>
|
||||
{errors.passengers?.[index]?.phone && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
|
||||
@@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => {
|
||||
return Smartphone;
|
||||
};
|
||||
|
||||
const NATIONALITY_TO_CURRENCY: Record<string, 'ETB' | 'DJF' | 'USD'> = {
|
||||
ETHIOPIAN: 'ETB',
|
||||
DJIBOUTIAN: 'DJF',
|
||||
};
|
||||
|
||||
export default function PaymentPage() {
|
||||
const router = useRouter();
|
||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore();
|
||||
const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
const displayCurrency: 'ETB' | 'DJF' | 'USD' =
|
||||
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD';
|
||||
const displayCurrency = 'ETB' as const;
|
||||
|
||||
// Keep payment store in sync so the mutation picks up the right currency.
|
||||
useEffect(() => {
|
||||
@@ -54,7 +50,22 @@ export default function PaymentPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate total amount
|
||||
// Fetch actual booking amount from API when a payment method is selected
|
||||
const amountCurrency = selectedMethodCurrency || displayCurrency;
|
||||
|
||||
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
||||
queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod],
|
||||
queryFn: async () => {
|
||||
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
||||
console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod });
|
||||
const response: any = await apiClient.get(url);
|
||||
console.log('[BookingAmount] Response:', response);
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedMethod && !!bookingId,
|
||||
});
|
||||
|
||||
// Fallback: estimate from local store while API hasn't responded yet
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce(
|
||||
(sum) => sum + (outboundSchedule.baseFareAdult || 0),
|
||||
0,
|
||||
@@ -69,8 +80,12 @@ export default function PaymentPage() {
|
||||
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = baseFare;
|
||||
|
||||
// API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency
|
||||
const totalAmount = bookingAmountData != null
|
||||
? Math.round(bookingAmountData.amount * 100)
|
||||
: baseFare;
|
||||
const confirmedCurrency = bookingAmountData?.currency || amountCurrency;
|
||||
|
||||
const paymentMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
@@ -248,7 +263,12 @@ export default function PaymentPage() {
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-1">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||
<span className="text-xl font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span>
|
||||
<span className="text-xl font-bold text-primary flex items-center gap-1.5">
|
||||
{loadingAmount && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
)}
|
||||
{confirmedCurrency} {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,15 +279,19 @@ export default function PaymentPage() {
|
||||
)}
|
||||
<button
|
||||
onClick={handlePayment}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
disabled={!selectedMethod || isProcessing || loadingAmount}
|
||||
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : loadingAmount ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating amount...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||
`Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => router.back()} disabled={isProcessing} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
@@ -337,7 +361,7 @@ export default function PaymentPage() {
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
|
||||
disabled={isProcessing || !method.enabled}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isSelected
|
||||
@@ -385,7 +409,10 @@ export default function PaymentPage() {
|
||||
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-primary">{displayCurrency} {(totalAmount / 100).toFixed(2)}</span>
|
||||
<span className="text-lg font-bold text-primary flex items-center gap-1.5">
|
||||
{loadingAmount && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{confirmedCurrency} {(totalAmount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{paymentError && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mb-2">⚠️ {paymentError}</p>
|
||||
@@ -397,15 +424,19 @@ export default function PaymentPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePayment}
|
||||
disabled={!selectedMethod || isProcessing}
|
||||
disabled={!selectedMethod || isProcessing || loadingAmount}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
|
||||
</span>
|
||||
) : loadingAmount ? (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Calculating...
|
||||
</span>
|
||||
) : (
|
||||
`Pay ${displayCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||
`Pay ${confirmedCurrency} ${(totalAmount / 100).toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -144,8 +144,8 @@ export default function ResultsPage() {
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } }));
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } }));
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
@@ -161,10 +161,9 @@ export default function ResultsPage() {
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code);
|
||||
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
|
||||
const minFare = coachType?.classes.length
|
||||
? Math.min(...coachType.classes.map(c => c.displayAmountMinor ?? c.baseFareMinor))
|
||||
? Math.min(...coachType.classes.map(c => c.baseFareMinor))
|
||||
: 0;
|
||||
const fareCurrency: string =
|
||||
coachType?.classes[0]?.displayCurrency ?? schedule.displayCurrency ?? 'ETB';
|
||||
const fareCurrency = 'ETB';
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||
@@ -186,6 +185,7 @@ export default function ResultsPage() {
|
||||
selectedCoachTypeId: selectedCoachType.id,
|
||||
selectedCoachTypeCode: selectedCoachType.code,
|
||||
selectedCoachTypeName: selectedCoachType.name,
|
||||
seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name,
|
||||
};
|
||||
|
||||
// For round trip, store outbound and wait for inbound selection
|
||||
@@ -222,17 +222,13 @@ export default function ResultsPage() {
|
||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||
let lowestFare = null;
|
||||
let displayCurrency = schedule.displayCurrency || 'ETB';
|
||||
const displayCurrency = 'ETB';
|
||||
if (schedule.coachTypes?.length) {
|
||||
const allClasses = schedule.coachTypes.flatMap(ct => ct.classes);
|
||||
const allFares = allClasses.map(c => c.displayAmountMinor ?? c.baseFareMinor).filter(f => f > 0);
|
||||
const allFares = allClasses.map(c => c.baseFareMinor).filter(f => f > 0);
|
||||
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||
const firstWithCurrency = allClasses.find(c => c.displayCurrency);
|
||||
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||
} else if (schedule.faresByClass?.length) {
|
||||
lowestFare = Math.min(...schedule.faresByClass.map(f => f.displayAmountMinor ?? f.baseFareMinor).filter(f => f > 0));
|
||||
const firstWithCurrency = schedule.faresByClass.find(f => f.displayCurrency);
|
||||
if (firstWithCurrency?.displayCurrency) displayCurrency = firstWithCurrency.displayCurrency;
|
||||
lowestFare = Math.min(...schedule.faresByClass.map(f => f.baseFareMinor).filter(f => f > 0));
|
||||
} else if (schedule.combinedMinFareDisplay) {
|
||||
lowestFare = schedule.combinedMinFareDisplay;
|
||||
}
|
||||
@@ -551,14 +547,14 @@ export default function ResultsPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeId;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor)) : 0;
|
||||
const coachCurrency: string = (coachType.classes[0] as any)?.displayCurrency ?? (classModal as any).displayCurrency ?? 'ETB';
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const coachCurrency = 'ETB';
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={coachType.coachId}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName)}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeId, coachType.coachTypeCode, coachType.coachTypeName, coachType.classes?.[0]?.name || coachType.coachTypeName)}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
|
||||
@@ -631,10 +627,10 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
||||
{(cls.baseFareMinor / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{cls.displayCurrency ?? coachCurrency}
|
||||
{coachCurrency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,11 +62,7 @@ export default function ReviewPage() {
|
||||
|
||||
// Prefer the currency already stored on the selected schedule (set from search results).
|
||||
// Fall back to deriving from nationality so the review page is never left with a stale value.
|
||||
const NATIONALITY_TO_CURRENCY: Record<string, string> = { ETHIOPIAN: 'ETB', DJIBOUTIAN: 'DJF' };
|
||||
const displayCurrency: string =
|
||||
(isRoundTrip ? outboundSchedule?.displayCurrency : selectedSchedule?.displayCurrency) ??
|
||||
NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ??
|
||||
'USD';
|
||||
const displayCurrency = 'ETB';
|
||||
|
||||
useEffect(() => {
|
||||
if (!seatHold?.expiresAt) return;
|
||||
@@ -201,19 +197,34 @@ export default function ReviewPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get seat class ID
|
||||
let seatClassId = 'default-seat-class-id';
|
||||
let returnSeatClassId = 'default-seat-class-id';
|
||||
// Get seat class ID by name-matching against the /seat-classes list
|
||||
let seatClassId = '';
|
||||
let returnSeatClassId = '';
|
||||
try {
|
||||
const seatClasses: any = await apiClient.get('/seat-classes');
|
||||
console.log('Seat classes:', seatClasses);
|
||||
const seatClasses: any[] = await apiClient.get('/seat-classes');
|
||||
if (seatClasses && seatClasses.length > 0) {
|
||||
seatClassId = seatClasses[0].id;
|
||||
returnSeatClassId = seatClasses[0].id;
|
||||
const outboundClassName = isRoundTrip
|
||||
? (outboundSchedule as any)?.seatClassName
|
||||
: (selectedSchedule as any)?.seatClassName;
|
||||
const returnClassName = isRoundTrip
|
||||
? (inboundSchedule as any)?.seatClassName
|
||||
: outboundClassName;
|
||||
|
||||
const findByName = (name: string) =>
|
||||
seatClasses.find((sc: any) => sc.name === name)?.id || seatClasses[0].id;
|
||||
|
||||
seatClassId = outboundClassName ? findByName(outboundClassName) : seatClasses[0].id;
|
||||
returnSeatClassId = returnClassName ? findByName(returnClassName) : seatClasses[0].id;
|
||||
console.log('Seat class lookup:', { outboundClassName, returnClassName, seatClassId, returnSeatClassId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch seat classes:', err);
|
||||
}
|
||||
|
||||
if (!seatClassId) {
|
||||
alert('Unable to determine seat class. Please go back and re-select your seats.');
|
||||
return;
|
||||
}
|
||||
|
||||
let bookingData: any;
|
||||
if (isAuthenticated) {
|
||||
|
||||
@@ -54,6 +54,10 @@ export interface SelectedSchedule {
|
||||
displayCurrency: string;
|
||||
selectedSeatClass?: string;
|
||||
selectedSeatClassName?: string;
|
||||
seatClassName?: string;
|
||||
selectedCoachTypeId?: string;
|
||||
selectedCoachTypeCode?: string;
|
||||
selectedCoachTypeName?: string;
|
||||
}
|
||||
|
||||
export interface SeatHold {
|
||||
|
||||
@@ -1,392 +1,301 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface VoucherData {
|
||||
interface ScheduleInfo {
|
||||
trainNumber: string;
|
||||
trainName?: string;
|
||||
origin: { name: string; code: string; city: string };
|
||||
destination: { name: string; code: string; city: string };
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
seatClass?: string;
|
||||
}
|
||||
|
||||
interface PassengerVoucherData {
|
||||
bookingRef: string;
|
||||
ticketNumber: string;
|
||||
passengerName: string;
|
||||
dateOfBirth?: string;
|
||||
nationality?: string;
|
||||
seatNumber?: string;
|
||||
outboundSeatNumber?: string;
|
||||
inboundSeatNumber?: string;
|
||||
status: string;
|
||||
passengers: Array<{
|
||||
fullName: string;
|
||||
category: string;
|
||||
seat?: {
|
||||
number: string;
|
||||
coach: string;
|
||||
seatClass: string;
|
||||
};
|
||||
}>;
|
||||
schedule: {
|
||||
trainNumber: string;
|
||||
trainName?: string;
|
||||
origin: {
|
||||
name: string;
|
||||
code: string;
|
||||
city: string;
|
||||
};
|
||||
destination: {
|
||||
name: string;
|
||||
code: string;
|
||||
city: string;
|
||||
};
|
||||
departureAt: string;
|
||||
arrivalAt: string;
|
||||
};
|
||||
totalMinor: number;
|
||||
outboundSchedule: ScheduleInfo;
|
||||
inboundSchedule?: ScheduleInfo;
|
||||
isRoundTrip: boolean;
|
||||
fareMinor: number;
|
||||
currency: string;
|
||||
bookingType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const generateVoucherPDF = async (booking: VoucherData) => {
|
||||
const doc = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
// ─── shared drawing helpers ───────────────────────────────────────────────────
|
||||
|
||||
const PRIMARY = [20, 113, 76] as const;
|
||||
const DARK = [51, 51, 51] as const;
|
||||
const MED = [102, 102, 102] as const;
|
||||
const LIGHT = [200, 200, 200] as const;
|
||||
|
||||
async function drawHeader(doc: jsPDF, margin: number): Promise<number> {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const margin = 15;
|
||||
let yPos = margin;
|
||||
|
||||
// Colors
|
||||
const primaryColor = [20, 113, 76]; // EDR Green
|
||||
const darkGray = [51, 51, 51];
|
||||
const mediumGray = [102, 102, 102];
|
||||
const lightGray = [200, 200, 200];
|
||||
|
||||
// ============ HEADER ============
|
||||
// Company branding strip
|
||||
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFillColor(...PRIMARY);
|
||||
doc.rect(0, 0, pageWidth, 30, 'F');
|
||||
|
||||
// Load and add logo
|
||||
try {
|
||||
const logoImg = await fetch('/edr-logo.png');
|
||||
const logoImg = await fetch('/edr-logo.png');
|
||||
const logoBlob = await logoImg.blob();
|
||||
const logoDataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(logoBlob);
|
||||
});
|
||||
|
||||
// Create image to get dimensions
|
||||
const img = new Image();
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
img.src = logoDataUrl;
|
||||
});
|
||||
|
||||
// Calculate aspect ratio and dimensions
|
||||
const logoHeight = 18;
|
||||
const logoWidth = (img.width / img.height) * logoHeight;
|
||||
|
||||
// Add logo on left side with proper aspect ratio
|
||||
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight);
|
||||
|
||||
// Company name next to logo
|
||||
await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; });
|
||||
const logoH = 18;
|
||||
const logoW = (img.width / img.height) * logoH;
|
||||
doc.addImage(logoDataUrl, 'PNG', margin, 6, logoW, logoH);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', margin + logoWidth + 5, 20);
|
||||
} catch (error) {
|
||||
console.error('Failed to load logo:', error);
|
||||
// Fallback: just show text centered
|
||||
doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoW + 5, 14);
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', margin + logoW + 5, 20);
|
||||
} catch {
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(24);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' });
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' });
|
||||
doc.setFontSize(22); doc.setFont('helvetica', 'bold');
|
||||
doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 13, { align: 'center' });
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Premium Travel Experience', pageWidth / 2, 20, { align: 'center' });
|
||||
}
|
||||
return 40;
|
||||
}
|
||||
|
||||
yPos = 40;
|
||||
|
||||
// ============ TITLE & STATUS ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' });
|
||||
|
||||
yPos += 10;
|
||||
|
||||
// Status badge (simplified)
|
||||
const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status;
|
||||
const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8];
|
||||
|
||||
doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]);
|
||||
doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F');
|
||||
function drawStatusBadge(doc: jsPDF, status: string, y: number, pageWidth: number): number {
|
||||
const label = (status === 'TICKETED' || status === 'CONFIRMED') ? 'CONFIRMED' : status;
|
||||
const color = (status === 'TICKETED' || status === 'CONFIRMED') ? [34, 197, 94] : [234, 179, 8];
|
||||
doc.setFillColor(color[0], color[1], color[2]);
|
||||
doc.rect(pageWidth / 2 - 22, y - 4, 44, 8, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' });
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'bold');
|
||||
doc.text(label, pageWidth / 2, y + 1, { align: 'center' });
|
||||
return y + 12;
|
||||
}
|
||||
|
||||
yPos += 12;
|
||||
|
||||
// ============ QR CODE ============
|
||||
// Generate QR code data URL
|
||||
const canvas = document.createElement('canvas');
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
|
||||
const qrSize = 35; // 35mm = 3.5cm
|
||||
await QRCode.toCanvas(canvas, booking.bookingRef, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
const qrDataUrl = canvas.toDataURL('image/png');
|
||||
|
||||
// Place QR code at top-right
|
||||
const qrX = pageWidth - margin - qrSize;
|
||||
const qrY = yPos;
|
||||
|
||||
doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' });
|
||||
|
||||
// ============ BOOKING REFERENCE ============
|
||||
function drawBookingRefBox(doc: jsPDF, bookingRef: string, ticketNumber: string, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setFillColor(245, 245, 245);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F');
|
||||
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('BOOKING REFERENCE', margin + 5, yPos + 6);
|
||||
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.bookingRef, margin + 5, yPos + 14);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 22, 'F');
|
||||
|
||||
yPos += 25;
|
||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.text('BOOKING REFERENCE', margin + 5, y + 6);
|
||||
doc.setTextColor(...PRIMARY); doc.setFontSize(16); doc.setFont('helvetica', 'bold');
|
||||
doc.text(bookingRef, margin + 5, y + 14);
|
||||
|
||||
// ============ JOURNEY DETAILS ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('JOURNEY DETAILS', margin, yPos);
|
||||
|
||||
yPos += 8;
|
||||
const rightX = pageWidth - margin - 5;
|
||||
doc.setTextColor(...MED); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TICKET NUMBER', rightX, y + 6, { align: 'right' });
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text(ticketNumber, rightX, y + 14, { align: 'right' });
|
||||
|
||||
// Route box
|
||||
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 40);
|
||||
return y + 28;
|
||||
}
|
||||
|
||||
function drawJourneyLeg(doc: jsPDF, schedule: ScheduleInfo, label: string | null, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text(label ? `JOURNEY DETAILS — ${label.toUpperCase()}` : 'JOURNEY DETAILS', margin, y);
|
||||
y += 7;
|
||||
|
||||
doc.setDrawColor(...LIGHT); doc.setLineWidth(0.5);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 40);
|
||||
|
||||
// Origin
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('FROM', margin + 5, yPos + 6);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.schedule.origin.code, margin + 5, yPos + 14);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(booking.schedule.origin.name, margin + 5, yPos + 20);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.text(booking.schedule.origin.city, margin + 5, yPos + 25);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('FROM', margin + 5, y + 6);
|
||||
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.origin.code, margin + 5, y + 14);
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text(schedule.origin.name, margin + 5, y + 20);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED);
|
||||
doc.text(schedule.origin.city, margin + 5, y + 25);
|
||||
|
||||
// Departure time
|
||||
const departureDate = new Date(booking.schedule.departureAt);
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38);
|
||||
const dep = new Date(schedule.departureAt);
|
||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(dep.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, y + 33);
|
||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text(dep.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, y + 38);
|
||||
|
||||
// Arrow
|
||||
doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setLineWidth(1);
|
||||
const arrowStartX = pageWidth / 2 - 10;
|
||||
const arrowEndX = pageWidth / 2 + 10;
|
||||
const arrowY = yPos + 20;
|
||||
|
||||
// Draw arrow line
|
||||
doc.line(arrowStartX, arrowY, arrowEndX, arrowY);
|
||||
|
||||
// Draw arrow head manually with lines
|
||||
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2);
|
||||
doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2);
|
||||
doc.setDrawColor(...PRIMARY); doc.setLineWidth(0.8);
|
||||
const ax = pageWidth / 2, ay = y + 20;
|
||||
doc.line(ax - 10, ay, ax + 10, ay);
|
||||
doc.line(ax + 10, ay, ax + 7, ay - 2);
|
||||
doc.line(ax + 10, ay, ax + 7, ay + 2);
|
||||
|
||||
// Destination
|
||||
const destX = pageWidth - margin - 50;
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('TO', destX, yPos + 6);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.schedule.destination.code, destX, yPos + 14);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(booking.schedule.destination.name, destX, yPos + 20);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.text(booking.schedule.destination.city, destX, yPos + 25);
|
||||
const dx = pageWidth - margin - 50;
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TO', dx, y + 6);
|
||||
doc.setFontSize(15); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.destination.code, dx, y + 14);
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal');
|
||||
doc.text(schedule.destination.name, dx, y + 20);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED);
|
||||
doc.text(schedule.destination.city, dx, y + 25);
|
||||
|
||||
// Arrival time
|
||||
const arrivalDate = new Date(booking.schedule.arrivalAt);
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38);
|
||||
const arr = new Date(schedule.arrivalAt);
|
||||
doc.setFontSize(13); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(arr.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), dx, y + 33);
|
||||
doc.setFontSize(7); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text(arr.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), dx, y + 38);
|
||||
|
||||
yPos += 48;
|
||||
y += 47;
|
||||
|
||||
// Train info
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F');
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('TRAIN', margin + 5, yPos + 5);
|
||||
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9);
|
||||
|
||||
if (booking.schedule.trainName) {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9);
|
||||
// Train info bar
|
||||
doc.setFillColor(248, 248, 248);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 12, 'F');
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('TRAIN', margin + 5, y + 5);
|
||||
doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text(schedule.trainNumber + (schedule.trainName ? ` — ${schedule.trainName}` : ''), margin + 20, y + 9);
|
||||
if (schedule.seatClass) {
|
||||
doc.setFont('helvetica', 'normal'); doc.setTextColor(...MED);
|
||||
doc.text(schedule.seatClass, pageWidth - margin - 5, y + 9, { align: 'right' });
|
||||
}
|
||||
|
||||
yPos += 18;
|
||||
return y + 18;
|
||||
}
|
||||
|
||||
// ============ PASSENGERS ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('PASSENGERS', margin, yPos);
|
||||
|
||||
yPos += 8;
|
||||
function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, margin: number): number {
|
||||
doc.setTextColor(...DARK); doc.setFontSize(11); doc.setFont('helvetica', 'bold');
|
||||
doc.text('PASSENGER DETAILS', margin, y);
|
||||
y += 7;
|
||||
|
||||
// Passenger table
|
||||
const passengerData = booking.passengers.map((p, idx) => [
|
||||
(idx + 1).toString(),
|
||||
p.fullName,
|
||||
p.category,
|
||||
p.seat?.number || '-',
|
||||
p.seat?.coach || '-',
|
||||
p.seat?.seatClass || '-',
|
||||
]);
|
||||
const rows: [string, string][] = [
|
||||
['Full Name', data.passengerName || '—'],
|
||||
['Date of Birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'],
|
||||
['Nationality', data.nationality || '—'],
|
||||
];
|
||||
|
||||
if (data.isRoundTrip) {
|
||||
rows.push(['Outbound Seat', data.outboundSeatNumber || '—']);
|
||||
rows.push(['Return Seat', data.inboundSeatNumber || '—']);
|
||||
} else {
|
||||
rows.push(['Seat', data.seatNumber || '—']);
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos,
|
||||
head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']],
|
||||
body: passengerData,
|
||||
theme: 'striped',
|
||||
headStyles: {
|
||||
fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]],
|
||||
textColor: [255, 255, 255],
|
||||
fontSize: 9,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
bodyStyles: {
|
||||
fontSize: 9,
|
||||
textColor: [darkGray[0], darkGray[1], darkGray[2]],
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [250, 250, 250],
|
||||
startY: y,
|
||||
body: rows,
|
||||
theme: 'plain',
|
||||
styles: { fontSize: 9, cellPadding: 3 },
|
||||
columnStyles: {
|
||||
0: { fontStyle: 'bold', textColor: [MED[0], MED[1], MED[2]], cellWidth: 45 },
|
||||
1: { textColor: [DARK[0], DARK[1], DARK[2]] },
|
||||
},
|
||||
alternateRowStyles: { fillColor: [248, 248, 248] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
|
||||
yPos = (doc as any).lastAutoTable.finalY + 10;
|
||||
return (doc as any).lastAutoTable.finalY + 8;
|
||||
}
|
||||
|
||||
// ============ PAYMENT SUMMARY ============
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('PAYMENT SUMMARY', margin, yPos);
|
||||
|
||||
yPos += 8;
|
||||
function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setFillColor(248, 248, 248);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 20, 'F');
|
||||
doc.setFontSize(9); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Fare', margin + 5, y + 7);
|
||||
doc.setFontSize(15); doc.setTextColor(...PRIMARY); doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${currency} ${(fareMinor / 100).toFixed(2)}`, pageWidth - margin - 5, y + 7, { align: 'right' });
|
||||
doc.setFontSize(9); doc.setTextColor(34, 197, 94); doc.setFont('helvetica', 'bold');
|
||||
doc.text('✓ PAID', margin + 5, y + 15);
|
||||
return y + 26;
|
||||
}
|
||||
|
||||
doc.setFillColor(250, 250, 250);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F');
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Total Amount', margin + 5, yPos + 7);
|
||||
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' });
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(34, 197, 94);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('✓ PAID', margin + 5, yPos + 15);
|
||||
|
||||
yPos += 28;
|
||||
|
||||
// ============ INSTRUCTIONS ============
|
||||
function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: number): number {
|
||||
doc.setFillColor(252, 211, 77);
|
||||
doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F');
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11);
|
||||
doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15);
|
||||
doc.rect(margin, y, pageWidth - margin * 2, 18, 'F');
|
||||
doc.setFontSize(9); doc.setTextColor(...DARK); doc.setFont('helvetica', 'bold');
|
||||
doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, y + 6);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
|
||||
doc.text('• Present this voucher at the terminal for boarding', margin + 5, y + 11);
|
||||
doc.text('• Arrive at least 30 minutes before departure', margin + 5, y + 15);
|
||||
return y + 24;
|
||||
}
|
||||
|
||||
// ============ FOOTER ============
|
||||
const footerY = pageHeight - 25;
|
||||
|
||||
doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]);
|
||||
doc.line(margin, footerY, pageWidth - margin, footerY);
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
function drawFooter(doc: jsPDF, createdAt: string): void {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const footerY = pageHeight - 22;
|
||||
|
||||
doc.setDrawColor(...LIGHT);
|
||||
doc.line(15, footerY, pageWidth - 15, footerY);
|
||||
doc.setFontSize(8); doc.setTextColor(...MED); doc.setFont('helvetica', 'normal');
|
||||
doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' });
|
||||
doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' });
|
||||
|
||||
doc.setFontSize(7);
|
||||
doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
|
||||
doc.text(`Generated: ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' });
|
||||
}
|
||||
|
||||
// Watermark (removed rotation as it may cause issues)
|
||||
doc.setTextColor(240, 240, 240);
|
||||
doc.setFontSize(50);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' });
|
||||
// ─── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Save PDF
|
||||
doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`);
|
||||
/** Generates and downloads one PDF voucher for a single passenger. */
|
||||
export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise<void> => {
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
const margin = 15;
|
||||
|
||||
let y = await drawHeader(doc, margin);
|
||||
|
||||
// Title
|
||||
doc.setTextColor(...DARK); doc.setFontSize(18); doc.setFont('helvetica', 'bold');
|
||||
doc.text('PASSENGER VOUCHER', pageW / 2, y, { align: 'center' });
|
||||
y += 10;
|
||||
|
||||
y = drawStatusBadge(doc, data.status, y, pageW);
|
||||
y = drawBookingRefBox(doc, data.bookingRef, data.ticketNumber, y, margin, pageW);
|
||||
y = drawJourneyLeg(doc, data.outboundSchedule, data.isRoundTrip ? 'Outbound' : null, y, margin, pageW);
|
||||
|
||||
if (data.isRoundTrip && data.inboundSchedule) {
|
||||
y = drawJourneyLeg(doc, data.inboundSchedule, 'Return', y, margin, pageW);
|
||||
}
|
||||
|
||||
y = drawPassengerDetails(doc, data, y, margin);
|
||||
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW);
|
||||
drawInstructions(doc, y, margin, pageW);
|
||||
drawFooter(doc, data.createdAt);
|
||||
|
||||
const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
doc.save(`Voucher_${safeName}.pdf`);
|
||||
};
|
||||
|
||||
// ─── legacy combined voucher (kept for backward compat) ──────────────────────
|
||||
|
||||
interface VoucherData {
|
||||
bookingRef: string;
|
||||
status: string;
|
||||
passengers: Array<{ fullName: string; category: string; seat?: { number: string; coach: string; seatClass: string } }>;
|
||||
schedule: { trainNumber: string; trainName?: string; origin: { name: string; code: string; city: string }; destination: { name: string; code: string; city: string }; departureAt: string; arrivalAt: string };
|
||||
totalMinor: number;
|
||||
currency: string;
|
||||
bookingType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
||||
for (let i = 0; i < booking.passengers.length; i++) {
|
||||
const p = booking.passengers[i];
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: booking.bookingRef,
|
||||
ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`,
|
||||
passengerName: p.fullName,
|
||||
seatNumber: p.seat?.number,
|
||||
status: booking.status,
|
||||
outboundSchedule: { ...booking.schedule, seatClass: p.seat?.seatClass },
|
||||
isRoundTrip: false,
|
||||
fareMinor: Math.round(booking.totalMinor / booking.passengers.length),
|
||||
currency: booking.currency,
|
||||
createdAt: booking.createdAt,
|
||||
});
|
||||
// small delay so browsers don't block multiple sequential downloads
|
||||
if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const GOVERNMENT_PRIORITY_BONUS = 50_000;
|
||||
|
||||
export interface GovernmentBookingFields {
|
||||
isGovernment: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
governmentInstitution?: string | null;
|
||||
}
|
||||
|
||||
export enum ExceededAction {
|
||||
@@ -129,6 +129,30 @@ export enum PaymentStatus {
|
||||
Refunded = "REFUNDED",
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
Draft = "DRAFT",
|
||||
Pending = "PENDING",
|
||||
Paid = "PAID",
|
||||
Overdue = "OVERDUE",
|
||||
Cancelled = "CANCELLED",
|
||||
Refunded = "REFUNDED",
|
||||
}
|
||||
|
||||
/** Originating subsystem an invoice bills for; namespaces invoice events. */
|
||||
export enum InvoiceSource {
|
||||
Booking = "booking",
|
||||
Warehouse = "warehouse",
|
||||
Demurrage = "demurrage",
|
||||
}
|
||||
|
||||
/**
|
||||
* What an invoice bills for within its source — the discriminator when one
|
||||
* entity carries several invoices (e.g. a booking's up-front vs final charge).
|
||||
*/
|
||||
export enum InvoiceType {
|
||||
Prepaid = "PREPAID",
|
||||
}
|
||||
|
||||
export enum SchedulingStatus {
|
||||
NotScheduled = "NOT_SCHEDULED",
|
||||
Holding = "HOLDING",
|
||||
@@ -500,14 +524,57 @@ export interface ClearanceView {
|
||||
allApproved: boolean;
|
||||
}
|
||||
|
||||
export interface IInvoice extends BaseEntity {
|
||||
bookingId: string;
|
||||
invoiceNumber: string;
|
||||
/** Company an invoice is billed to (minimal projection). */
|
||||
export interface IInvoiceCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Company profile (importer/exporter/forwarder/…) an invoice is billed to. */
|
||||
export interface IInvoiceCompanyProfile {
|
||||
id: string;
|
||||
type: string;
|
||||
reference: string | null;
|
||||
}
|
||||
|
||||
/** A single billed line on an invoice. */
|
||||
export interface IInvoiceLine extends BaseEntity {
|
||||
invoiceId: string;
|
||||
chargeType: string;
|
||||
description?: string | null;
|
||||
/** Units billed (container count, wagon count, tons, …); defaults to 1. */
|
||||
quantity: number;
|
||||
/** Price per unit; `amount` is normally `quantity * unitRate`. */
|
||||
unitRate: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: PaymentStatus;
|
||||
issuedAt: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IInvoice extends BaseEntity {
|
||||
invoiceNumber: string;
|
||||
/** Customer (company) the invoice is billed to. */
|
||||
companyId: string;
|
||||
company?: IInvoiceCompany;
|
||||
/** Specific company profile billed. */
|
||||
companyProfileId: string;
|
||||
companyProfile?: IInvoiceCompanyProfile;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: InvoiceStatus;
|
||||
/** Originating subsystem: booking / warehouse / demurrage. */
|
||||
source: InvoiceSource;
|
||||
/** Identifier of the source record (e.g. booking id). */
|
||||
sourceId: string;
|
||||
/** What the invoice bills for (e.g. PREPAID). */
|
||||
type: string;
|
||||
/** Set when issued; null while DRAFT. */
|
||||
issuedAt?: string | null;
|
||||
/** Gateway payment that settled the invoice, once paid. */
|
||||
paymentId?: string | null;
|
||||
dueAt: string;
|
||||
/** Present on invoice-detail reads. */
|
||||
lines?: IInvoiceLine[];
|
||||
}
|
||||
|
||||
// ── Reference Data (booking form catalog) ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user