From aa59e30ea789c243db21eb77e309e0ab96c749a8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 13:38:48 +0000 Subject: [PATCH 01/64] feat: setup invoice entityt --- .../billing/entities/invoice-line.entity.ts | 43 ++++++++++++ .../billing/entities/invoice.entity.ts | 67 +++++++++++++++---- packages/types/src/freight/index.ts | 11 ++- 3 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts new file mode 100644 index 000000000..a042dedb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "./invoice.entity"; + +@Entity({ schema: "freight", name: "invoice_lines" }) +export class InvoiceLine extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid", nullable: false }) + invoiceId!: string; + + @ManyToOne(() => Invoice, { onDelete: "CASCADE" }) + @JoinColumn({ name: "invoice_id" }) + invoice!: Invoice; + + @Column({ name: "charge_type", type: "varchar", nullable: false }) + chargeType!: string; + + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string; + + /** Units this line bills for (e.g. container count, wagon count, tons). */ + @Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 }) + quantity!: number; + + /** Price per unit; `amount` is normally `quantity * unitRate`. */ + @Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ + name: "amount", + type: "numeric", + precision: 14, + scale: 2, + nullable: false, + }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ name: "metadata", type: "jsonb", nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index e2a6f7cc2..61bc9c16b 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,17 +1,35 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { PaymentEntity } from "../../payment/entities/payment.entity"; +import { Company } from "../../companies/entities/company.entity"; +import { CompanyProfile } from "../../companies/entities/company-profile.entity"; -@Entity({schema:"freight", name: "invoices" }) +@Entity({ schema: "freight", name: "invoices" }) +@Index(["companyId"]) +@Index(["companyProfileId"]) export class Invoice extends BaseEntity { - @Column({ name: "booking_id", type: "uuid" }) - bookingId!: string; - @Column({ name: "invoice_number", type: "varchar", length: 64, unique: true }) invoiceNumber!: string; - @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) - amount!: number; + /** The customer (company) this invoice is billed to. */ + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** The specific company profile (importer/exporter/forwarder/...) billed. */ + @Column({ name: "company_profile_id", type: "uuid" }) + companyProfileId!: string; + + @ManyToOne(() => CompanyProfile) + @JoinColumn({ name: "company_profile_id" }) + companyProfile?: CompanyProfile; + + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) + totalAmount!: number; @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -19,13 +37,38 @@ export class Invoice extends BaseEntity { @Column({ name: "status", type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + enum: Freight.InvoiceStatus, + default: Freight.InvoiceStatus.Draft, }) - status!: Freight.PaymentStatus; + status!: Freight.InvoiceStatus; - @Column({ name: "issued_at", type: "timestamptz" }) - issuedAt!: Date; + /** The source of the payment (e.g. booking, customer, etc.). */ + @Column({ name: "source", type: "varchar", length: 255, nullable: false }) + source!: string; + + /** The ID of the source (e.g. booking ID, customer ID, etc.). */ + @Column({ name: "source_id", type: "varchar", length: 255, nullable: false }) + sourceId!: string; + + /** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */ + @Column({ + type: "varchar", + length: 255, + nullable: false, + }) + type!: string; + + /** Set when the invoice is actually issued (DRAFT invoices leave this null). */ + @Column({ name: "issued_at", type: "timestamptz", nullable: true }) + issuedAt?: Date | null; + + /** The ID of the payment that generated this invoice. */ + @Column({ name: "payment_id", type: "uuid", nullable: true }) + paymentId?: string | null; + + @ManyToOne(() => PaymentEntity) + @JoinColumn({ name: "payment_id" }) + payment?: PaymentEntity; @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 412e7efa3..040848021 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -23,7 +23,7 @@ export const GOVERNMENT_PRIORITY_BONUS = 50_000; export interface GovernmentBookingFields { isGovernment: boolean; -governmentInstitution?: string | null; + governmentInstitution?: string | null; } export enum ExceededAction { @@ -128,6 +128,15 @@ export enum PaymentStatus { Refunded = "REFUNDED", } +export enum InvoiceStatus { + Draft = "DRAFT", + Pending = "PENDING", + Paid = "PAID", + Overdue = "OVERDUE", + Cancelled = "CANCELLED", + Refunded = "REFUNDED", +} + export enum SchedulingStatus { NotScheduled = "NOT_SCHEDULED", Holding = "HOLDING", From 15c37fea99388fcdf447e52f4377f4d95fa90a32 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 13:42:48 +0000 Subject: [PATCH 02/64] chore: setup repos for invoice --- .../src/modules/billing/billing.module.ts | 7 +++++-- .../modules/billing/invoice-line.repository.ts | 15 +++++++++++++++ .../src/modules/billing/invoice.repository.ts | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts create mode 100644 apps/edr-freight-api/src/modules/billing/invoice.repository.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 2b16b1515..1dd745088 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -4,11 +4,14 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./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"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice])], + imports: [TypeOrmModule.forFeature([Invoice, InvoiceLine])], controllers: [BillingController], - providers: [BillingService], + providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) export class BillingModule {} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts new file mode 100644 index 000000000..6a5482543 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { InvoiceLine } from "./entities/invoice-line.entity"; + +@Injectable() +export class InvoiceLineRepository extends BaseRepository { + constructor( + @InjectRepository(InvoiceLine) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts new file mode 100644 index 000000000..cc2e89df7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Invoice } from "./entities/invoice.entity"; + +@Injectable() +export class InvoiceRepository extends BaseRepository { + constructor( + @InjectRepository(Invoice) repository: Repository, + ) { + super(repository); + } +} From 1e27b96708ee2ac16eee38a1f3edc07f5c9a344b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 27 Jun 2026 07:18:50 +0000 Subject: [PATCH 03/64] chore: setup migration for invoice --- .../1821000000002-CreateInvoices.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts new file mode 100644 index 000000000..5c42cad65 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -0,0 +1,101 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Freight billing — `invoices` + `invoice_lines` tables. + * + * Matches: + * - billing/entities/invoice.entity.ts + * - billing/entities/invoice-line.entity.ts + * + * The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default + * enum-type name (`__enum`) so the entity's `type: "enum"` + * column resolves to it without an explicit `enumName`. + */ +export class CreateInvoices1821000000002 implements MigrationInterface { + name = "CreateInvoices1821000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + + await queryRunner.query(` + CREATE TABLE freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + + await queryRunner.query(` + CREATE TABLE freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`); + } +} From 55c42058d0fdcd9609a9cccbddcc4850a270b91b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 27 Jun 2026 08:42:18 +0000 Subject: [PATCH 04/64] chore: updating billing logic --- .../src/modules/billing/billing.controller.ts | 14 +- .../modules/billing/billing.service.spec.ts | 147 ++++++++ .../src/modules/billing/billing.service.ts | 323 +++++++++++++++++- 3 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/billing.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5a801cf73..e3778e11b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; +import { Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FreightAdmin } from "../../common/booking-guards"; @@ -21,4 +21,16 @@ export class BillingController { 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); + } + + @Post("invoices/generate/:bookingId") + @ApiOperation({ summary: "Generate (or return the existing) invoice for a booking" }) + generate(@Param("bookingId", ParseUUIDPipe) bookingId: string) { + return this.billingService.generateForBooking(bookingId); + } } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts new file mode 100644 index 000000000..4e2d27bb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -0,0 +1,147 @@ +import { Freight } from "@edr/types"; + +import { BillingService } from "./billing.service"; +import type { Invoice } from "./entities/invoice.entity"; + +/** + * Minimal in-memory EntityManager stand-in covering the methods + * `generateForBooking` / `markBookingInvoicePaid` call on the transaction manager. + */ +function makeManager(savedLines: unknown[]) { + return { + create: (_entity: unknown, data: Record) => data, + save: (data: Record) => { + const row = { id: data.id ?? `gen-${Math.round(0)}`, ...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 makeBooking(overrides: Record = {}) { + return { + id: "booking-1", + reference: "BK-001", + companyId: "company-1", + companyProfileId: "profile-1", + paymentCurrency: "ETB", + totalAmount: 1500, + pricingBreakdown: { + currency: "ETB", + totalAmount: 1500, + lineItems: [ + { code: "RAIL_FREIGHT", description: "Rail freight", amount: 1000, unitAmount: 500, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" }, + { code: "HAZARD_SURCHARGE", description: "Hazard surcharge", amount: 500, unitAmount: 250, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" }, + ], + }, + ...overrides, + }; +} + +describe("BillingService.generateForBooking", () => { + let invoices: { findAll: jest.Mock; findById: jest.Mock }; + let invoiceLines: { findAll: jest.Mock }; + let savedLines: unknown[]; + let manager: ReturnType; + let bookingRow: Record | null; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + manager: unknown; + }; + let service: BillingService; + + beforeEach(() => { + savedLines = []; + manager = makeManager(savedLines); + invoices = { findAll: jest.fn().mockResolvedValue([]), findById: jest.fn() }; + invoiceLines = { findAll: jest.fn().mockResolvedValue([]) }; + bookingRow = makeBooking(); + dataSource = { + getRepository: jest.fn().mockReturnValue({ + findOne: jest.fn().mockImplementation(() => Promise.resolve(bookingRow)), + }), + transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + service = new BillingService(dataSource as never, invoices as never, invoiceLines as never); + }); + + it("creates a PENDING invoice with one line per pricing line item", async () => { + const invoice = (await service.generateForBooking("booking-1")) as Invoice; + + expect(invoice).toBeTruthy(); + expect(invoice.status).toBe(Freight.InvoiceStatus.Pending); + expect(invoice.companyId).toBe("company-1"); + expect(invoice.companyProfileId).toBe("profile-1"); + expect(invoice.source).toBe("booking"); + expect(invoice.sourceId).toBe("booking-1"); + expect(invoice.totalAmount).toBe(1500); + expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(savedLines).toHaveLength(2); + }); + + it("returns the existing active invoice instead of creating a duplicate", async () => { + const existing = { id: "inv-existing", status: Freight.InvoiceStatus.Pending } as Invoice; + invoices.findAll.mockResolvedValueOnce([existing]); + + const invoice = await service.generateForBooking("booking-1"); + + expect(invoice).toBe(existing); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("skips generation (returns null) when the booking has no company to bill", async () => { + bookingRow = makeBooking({ companyId: null, companyProfileId: null }); + + const invoice = await service.generateForBooking("booking-1"); + + expect(invoice).toBeNull(); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("falls back to a single freight line when no pricing breakdown exists", async () => { + bookingRow = makeBooking({ pricingBreakdown: null }); + + const invoice = (await service.generateForBooking("booking-1")) as Invoice; + + expect(invoice.totalAmount).toBe(1500); + expect(savedLines).toHaveLength(1); + expect((savedLines[0] as { chargeType: string }).chargeType).toBe("FREIGHT"); + }); +}); + +describe("BillingService.markBookingInvoicePaid", () => { + it("marks the open booking invoice PAID and links the payment", async () => { + const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { manager: mg } as never; + const service = new BillingService(dataSource, {} as never, {} as never); + + await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never); + + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + }); + + it("is a no-op when the booking has no open invoice", async () => { + const mg = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService({ manager: mg } as never, {} as never, {} as never); + + await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39eae6ef5..fd95a12f6 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,26 +1,333 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { Freight } from "@edr/types"; +import { DataSource, EntityManager, In } from "typeorm"; +import { Booking } from "../bookings/entities/booking.entity"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; + +/** Source tag stamped on booking invoices (`source` column). */ +const BOOKING_SOURCE = "booking"; + +/** Default invoice payment-term window, in days, used to compute `dueAt`. */ +const DEFAULT_DUE_DAYS = 14; + +/** + * Statuses an invoice can hold while it still represents the live bill for a + * booking. A second `generateForBooking` call returns the existing one of these + * instead of creating a duplicate (idempotency / dedup guard). + */ +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.Paid, + Freight.InvoiceStatus.Overdue, +]; + +/** Shape of a single line inside `booking.pricingBreakdown.lineItems`. */ +interface StoredPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + unit: string; + quantity: number; + currency: string; +} + +interface StoredPricingBreakdown { + lineItems?: StoredPriceLine[]; + totalAmount?: number; + currency?: string; +} + +/** A fully-resolved invoice line ready to persist. */ +interface BuiltLine { + chargeType: string; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + metadata?: Record; +} @Injectable() export class BillingService { + private readonly logger = new Logger(BillingService.name); + constructor( - @InjectRepository(Invoice) - private readonly invoicesRepository: Repository, + private readonly dataSource: DataSource, + private readonly invoices: InvoiceRepository, + private readonly invoiceLines: InvoiceLineRepository, ) {} + // ── Reads ────────────────────────────────────────────────────────────────── + /** List every invoice (most recent first). */ findAll(): Promise { - return this.invoicesRepository.find({ order: { issuedAt: "DESC" } }); + return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } /** List invoices for a given booking. */ findByBooking(bookingId: string): Promise { - return this.invoicesRepository.find({ - where: { bookingId }, + return this.invoices.findAll({ + where: { source: BOOKING_SOURCE, sourceId: bookingId }, order: { issuedAt: "DESC" }, }); } + + /** Invoice header plus its line items. */ + async findById(id: string): Promise { + const invoice = await this.invoices.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const lines = await this.invoiceLines.findAll({ + where: { invoiceId: id }, + order: { createdAt: "ASC" }, + }); + return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + } + + // ── Generation ─────────────────────────────────────────────────────────────── + + /** + * Generate the booking's invoice from its snapshotted pricing breakdown. + * + * Called when a booking reaches a billable state (full contract execution / + * contract activation). Idempotent: a booking that already has an active + * invoice gets that invoice back instead of a duplicate. + * + * Returns `null` (and logs) when the booking is not billable yet — no pricing + * breakdown, or no customer company/profile to bill (e.g. government/legacy + * bookings whose `company_id` is null, which the `invoices` FK requires). + */ + async generateForBooking(bookingId: string): Promise { + const existing = await this.invoices.findAll({ + where: { source: BOOKING_SOURCE, sourceId: bookingId }, + }); + const active = existing.find((inv) => ACTIVE_STATUSES.includes(inv.status)); + if (active) return active; + + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + if (!booking.companyId) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${bookingId}): no company to bill.`, + ); + return null; + } + const companyId = booking.companyId; + const companyProfileId = booking.companyProfileId ?? null; + + const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; + const lines = this.buildLines(breakdown, booking.totalAmount, currency); + const subtotal = round2(lines.reduce((sum, l) => sum + l.amount, 0)); + + // Honor a staff price override: bill the adjusted total, recording the delta + // as an ADJUSTMENT line so the signed lines still sum to the invoice total. + const adjusted = booking.adjustedTotalAmount; + let total = subtotal; + 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, + }); + } + total = round2(Number(adjusted)); + } + + const issuedAt = new Date(); + const dueAt = new Date(issuedAt); + dueAt.setDate(dueAt.getDate() + DEFAULT_DUE_DAYS); + + return this.dataSource.transaction(async (mg) => { + const invoice = await mg.save( + mg.create(Invoice, { + invoiceNumber: await this.nextInvoiceNumber(mg), + companyId, + companyProfileId, + totalAmount: total, + currency, + status: Freight.InvoiceStatus.Pending, + source: BOOKING_SOURCE, + sourceId: bookingId, + type: "PREPAID", + issuedAt, + dueAt, + }), + ); + + for (const line of lines) { + await mg.save(mg.create(InvoiceLine, { invoiceId: invoice.id, ...line })); + } + + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} for booking ${booking.reference} (${total} ${currency}).`, + ); + return invoice; + }); + } + + /** Map the stored pricing line items to invoice lines (one breakdown line → one invoice line). */ + private buildLines( + breakdown: StoredPricingBreakdown, + fallbackTotal: number, + currency: string, + ): BuiltLine[] { + const items = breakdown.lineItems ?? []; + if (items.length === 0) { + // No itemized breakdown — bill a single line for the booking total. + return [ + { + chargeType: "FREIGHT", + description: "Freight charge", + quantity: 1, + unitRate: round2(fallbackTotal), + amount: round2(fallbackTotal), + currency, + }, + ]; + } + + return items.map((item) => ({ + chargeType: item.code, + description: item.description, + quantity: item.quantity, + unitRate: round2(item.unitAmount), + amount: round2(item.amount), + currency: item.currency ?? currency, + metadata: { unit: item.unit }, + })); + } + + /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ + private async nextInvoiceNumber(mg: EntityManager): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `FRT-${ymd}-`; + const [row] = await mg.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; + } + + // ── Payment reconciliation ─────────────────────────────────────────────────── + + /** + * The invoice a gateway payment should settle for a booking, or null if none. + * + * This is the billing document of record for "what is owed" — callers (e.g. + * `payment.service.initiatePayment`) should charge `invoice.totalAmount` against + * it rather than recomputing from `booking.totalAmount`, so discounts/penalties/ + * adjustments carried on the invoice are honored. Returns the most recent open + * (unpaid, non-cancelled) invoice. + */ + findPayableForBooking(bookingId: string): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source: BOOKING_SOURCE, + sourceId: bookingId, + status: In([ + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Overdue, + ]), + }, + order: { issuedAt: "DESC" }, + }); + } + + /** + * Mark a booking's paid invoice as refunded. Called from `payment.service.refund` + * inside its DB transaction so the invoice tracks the booking/payment reversal. + * No-op when the booking has no paid invoice. + */ + async markBookingInvoiceRefunded( + bookingId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source: BOOKING_SOURCE, + sourceId: bookingId, + status: Freight.InvoiceStatus.Paid, + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + + await mg.update( + Invoice, + { id: invoice.id }, + { status: Freight.InvoiceStatus.Refunded }, + ); + } + + /** + * Mark a booking's open invoice as paid and link the gateway payment. + * + * Called from `payment.service.finalizePaymentSuccess()` inside its existing DB + * transaction (pass the transaction's `EntityManager`). Full-payment only — no + * partial settlement in this phase. No-op when the booking has no open invoice. + */ + async markBookingInvoicePaid( + bookingId: string, + paymentId: string | null, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source: BOOKING_SOURCE, + sourceId: bookingId, + status: In([Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Overdue]), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + + await mg.update( + Invoice, + { id: invoice.id }, + { status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined }, + ); + } + + /** + * Single settlement chokepoint for any path that marks a booking PAID: ensure + * the booking has an invoice (idempotent generate), then mark it paid. Reused by + * the gateway flow and offline/manual "mark paid" so invoicing holds everywhere. + * + * No-op for bookings that have no invoice and cannot get one (e.g. government + * bookings with no company to bill — `generateForBooking` returns null). + */ + async settleBookingInvoice( + bookingId: string, + paymentId: string | null = null, + manager?: EntityManager, + ): Promise { + await this.generateForBooking(bookingId); + await this.markBookingInvoicePaid(bookingId, paymentId, manager); + } +} + +/** Round to 2 decimal places without float drift. */ +function round2(n: number): number { + return Math.round(Number(n) * 100) / 100; } From 98e34593c4eaddf3373c918cd2ffc4788fbac0d3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 27 Jun 2026 09:08:15 +0000 Subject: [PATCH 05/64] feat: add government support to the companies. add seeding and constrain on booking --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 6 + ...000003-AddCompanyKindAndGovBookingLinks.ts | 129 +++++++++++++++++ .../booking-orders/booking-orders.service.ts | 5 +- .../src/modules/bookings/bookings.service.ts | 46 +++++- .../bookings/dto/create-booking.dto.ts | 23 ++- .../bookings/entities/booking.entity.ts | 15 +- .../modules/companies/companies.repository.ts | 6 +- .../modules/companies/companies.service.ts | 23 +++ .../companies/dto/list-companies-query.dto.ts | 7 +- .../companies/entities/company.entity.ts | 21 +++ .../src/scripts/seed-gov-companies.ts | 28 ++++ .../src/seed/data/gov-companies.data.ts | 136 ++++++++++++++++++ .../src/seed/gov-companies.seeder.ts | 72 ++++++++++ 14 files changed, 495 insertions(+), 23 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-gov-companies.ts create mode 100644 apps/edr-freight-api/src/seed/data/gov-companies.data.ts create mode 100644 apps/edr-freight-api/src/seed/gov-companies.seeder.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b26764d30..822e6f5e1 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -20,6 +20,7 @@ "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.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" }, "dependencies": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..a598da8e4 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -54,6 +54,7 @@ import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; import { WarehouseDemoSeeder } from "./seed/warehouse-demo.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"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -139,6 +140,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, DemoFreightDataSeeder, + GovCompaniesSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, Batch5TestDataSeeder, @@ -163,6 +165,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { @@ -187,5 +190,8 @@ export class AppModule implements OnApplicationBootstrap { // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval // rules are disabled inside the seeder). Kept running for the staff users. await this.demoFreightDataSeeder.run(); + // Government entities (with importer/exporter profiles) that government + // bookings bill to. Idempotent — keyed by fixed IDs. + await this.govCompaniesSeeder.run(); } } diff --git a/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts new file mode 100644 index 000000000..95ea3db1b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts @@ -0,0 +1,129 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Government bookings now bill to a real seeded government company + an explicit + * importer/exporter profile, instead of carrying a null company + free-text + * institution. This migration: + * + * 1. Adds `companies.kind` (commercial | government). + * 2. Seeds the Ethiopian government entities + their importer/exporter + * profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync). + * 3. Backfills every booking with a NULL company_id / company_profile_id so + * the NOT NULL constraints below can be applied: + * - NULL company_id → the default government company. + * - NULL company_profile_id → the company's profile matching the booking + * trade direction; else any profile of the company; else the default + * government importer profile. + * 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id. + */ +export class AddCompanyKindAndGovBookingLinks1821000000003 + implements MigrationInterface +{ + name = "AddCompanyKindAndGovBookingLinks1821000000003"; + + // Mirrors src/seed/data/gov-companies.data.ts + private readonly govCompanies = [ + { id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" }, + { id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" }, + { id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" }, + { id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" }, + { id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" }, + { id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" }, + ]; + + private get defaultCompanyId(): string { + return this.govCompanies[0].id; + } + private get defaultImporterProfileId(): string { + return this.govCompanies[0].im; + } + + public async up(queryRunner: QueryRunner): Promise { + // 1. kind column + await queryRunner.query( + `ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`, + ); + + // 2. seed government companies + importer/exporter profiles (idempotent) + for (const g of this.govCompanies) { + await queryRunner.query( + `INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone") + VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5) + ON CONFLICT ("id") DO NOTHING`, + [g.id, g.name, g.tin, g.email, g.phone], + ); + await queryRunner.query( + `INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status") + VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active') + ON CONFLICT ("id") DO NOTHING`, + [g.im, g.id, g.imRef, g.ex, g.exRef], + ); + } + + // 3a. backfill NULL company_id → default government company + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`, + [this.defaultCompanyId], + ); + + // 3b. backfill NULL company_profile_id → profile matching trade direction + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = cp."id" + FROM "freight"."company_profiles" cp + WHERE b."company_profile_id" IS NULL + AND cp."company_id" = b."company_id" + AND cp."deleted_at" IS NULL + AND cp."type" = CASE b."trade_direction" + WHEN 'IMPORT' THEN 'importer' + WHEN 'EXPORT' THEN 'exporter' + ELSE NULL END`, + ); + + // 3c. fallback → any profile of the booking's company + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = ( + SELECT cp."id" FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL + ORDER BY cp."created_at" ASC LIMIT 1) + WHERE b."company_profile_id" IS NULL + AND EXISTS ( + SELECT 1 FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`, + ); + + // 3d. final fallback → default government importer profile + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`, + [this.defaultImporterProfileId], + ); + + // 4. enforce NOT NULL + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`, + ); + // Seeded government rows are intentionally left in place. + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index 3c5bc4019..210b65b3d 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -312,8 +312,9 @@ export class BookingOrdersService { const child = manager.create(Booking, { reference, - companyId: contract.companyId ?? null, - companyProfileId: contract.companyProfileId ?? null, + // Drawdown orders inherit the contract's company + profile (both required). + companyId: contract.companyId, + companyProfileId: contract.companyProfileId, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, contractType: contract.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index c47064014..578c181bc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -11,7 +11,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'; @@ -299,10 +299,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( @@ -375,7 +388,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 { @@ -405,6 +427,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) @@ -435,10 +467,10 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, - companyId: companyId ?? null, + companyId, companyProfileId, isGovernment, - governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, + governmentInstitution: dto.governmentInstitution?.trim() || null, trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 677ef03fd..9380faba5 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -14,7 +14,6 @@ import { Max, MaxLength, Min, - MinLength, Validate, ValidateIf, ValidateNested, @@ -104,19 +103,31 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isGovernment?: boolean; - @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) - @ValidateIf((o) => o.isGovernment === true) + /** @deprecated Government bookings now bill to a real government company. */ + @ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' }) + @IsOptional() @IsString() - @MinLength(2) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) governmentInstitution?: string; - @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) - @ValidateIf((o) => o.isGovernment !== true) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.', + }) @IsOptional() @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 00f6e41c1..d95f5997c 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -105,8 +105,10 @@ export class Booking extends BaseEntity { // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; - @Column({ name: 'company_id', type: 'uuid', nullable: true }) - companyId?: string | null; + // Every booking is billed to a company — government bookings bill to a seeded + // government company (companies.kind = 'government'). Enforced NOT NULL. + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) @@ -116,11 +118,12 @@ export class Booking extends BaseEntity { * The operational profile (importer/exporter/forwarder) this booking belongs * to. Stamped at creation from the booking's trade direction (IMPORT→importer, * EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder. - * Customer portal lists and dashboard KPIs are scoped by this. Nullable for - * legacy/government/staff-created bookings. + * Customer portal lists and dashboard KPIs are scoped by this. Required: + * commercial bookings resolve it from trade direction / active mode; + * government bookings carry the explicitly-picked government profile. */ - @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) - companyProfileId?: string | null; + @Column({ name: 'company_profile_id', type: 'uuid' }) + companyProfileId!: string; @ManyToOne(() => CompanyProfile, { nullable: true }) @JoinColumn({ name: 'company_profile_id' }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index b31f2939d..15ca85c73 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, status } = query; + const { page = 1, pageSize = 20, search, type, kind, status } = query; const qb = this.repository .createQueryBuilder('company') @@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.type = :type', { type }); } + if (kind) { + qb.andWhere('company.kind = :kind', { kind }); + } + if (status) { qb.andWhere('company.status = :status', { status }); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index a838495d5..02f77b2e0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -337,6 +337,29 @@ export class CompaniesService { return company; } + /** + * Validate an explicitly-chosen company profile for a booking: it must belong + * to the booking's company and be Active. Used for government bookings (staff + * pick the profile) and any staff booking that pins a profile directly. + */ + async getActiveCompanyProfileForBooking( + companyId: string, + profileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(profileId); + if (!profile || profile.companyId !== companyId) { + throw new BadRequestException( + "Selected company profile does not belong to the chosen company", + ); + } + if (profile.status !== ProfileStatus.Active) { + throw new BadRequestException( + "Selected company profile is not active", + ); + } + return profile; + } + async getCompanyInfoByUserId( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index c92592286..4dbb932cb 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,7 +1,7 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; -import { CompanyStatus, CompanyType } from "../entities/company.entity"; +import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; export class ListCompaniesQueryDto { @ApiPropertyOptional({ default: 1 }) @@ -28,6 +28,11 @@ export class ListCompaniesQueryDto { @IsIn(Object.values(CompanyType)) type?: CompanyType; + @ApiPropertyOptional({ enum: CompanyKind }) + @IsOptional() + @IsIn(Object.values(CompanyKind)) + kind?: CompanyKind; + @ApiPropertyOptional({ enum: CompanyStatus }) @IsOptional() @IsIn(Object.values(CompanyStatus)) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 6702f9f7c..5fe3a3f67 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -10,6 +10,16 @@ export enum CompanyType { Transporter = "transporter", } +/** + * Sector of the company — orthogonal to {@link CompanyType} (the trade role). + * Government bookings are billed to a single seeded `GOVERNMENT` company instead + * of carrying a null company + free-text institution. + */ +export enum CompanyKind { + Commercial = "commercial", + Government = "government", +} + export enum CompanyStatus { Active = "active", Pending = "pending", @@ -25,6 +35,7 @@ export enum CompanyNationality { @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) +@Index(["kind"]) export class Company extends BaseEntity { @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -32,6 +43,16 @@ export class Company extends BaseEntity { @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType }) type!: CompanyType; + /** Commercial customer vs. the seeded government entity. */ + @Column({ + name: "kind", + type: "varchar", + length: 20, + default: CompanyKind.Commercial, + enum: CompanyKind, + }) + kind!: CompanyKind; + @Column({ name: "status", type: "varchar", diff --git a/apps/edr-freight-api/src/scripts/seed-gov-companies.ts b/apps/edr-freight-api/src/scripts/seed-gov-companies.ts new file mode 100644 index 000000000..41c027905 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-gov-companies.ts @@ -0,0 +1,28 @@ +import "reflect-metadata"; +import { config } from "dotenv"; +import { resolve } from "path"; + +config({ path: resolve(__dirname, "../../.env") }); + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "../app.module"; +import { GovCompaniesSeeder } from "../seed/gov-companies.seeder"; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ["error", "warn", "log"], + }); + + try { + const seeder = app.get(GovCompaniesSeeder); + await seeder.run(); + console.log("Government companies seeded."); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error("Government companies seed failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/data/gov-companies.data.ts b/apps/edr-freight-api/src/seed/data/gov-companies.data.ts new file mode 100644 index 000000000..841441147 --- /dev/null +++ b/apps/edr-freight-api/src/seed/data/gov-companies.data.ts @@ -0,0 +1,136 @@ +import { + CompanyKind, + CompanyStatus, + CompanyType, +} from "../../modules/companies/entities/company.entity"; +import { + ProfileStatus, + ProfileType, +} from "../../modules/companies/entities/company-profile.entity"; + +/** + * Canonical list of seeded Ethiopian government entities. Government bookings + * are billed to one of these (with an explicit importer/exporter profile) + * instead of carrying a null company + free-text institution. + * + * IDs are fixed so the seeder is idempotent and the matching migration + * (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to + * the same companies. The migration mirrors these rows in raw SQL — keep both + * in sync when adding new entities. + */ + +export const GOV_COMPANY_TYPE = CompanyType.Customer; +export const GOV_COMPANY_KIND = CompanyKind.Government; +export const GOV_COMPANY_STATUS = CompanyStatus.Active; +export const GOV_PROFILE_STATUS = ProfileStatus.Active; + +export interface GovProfileSeed { + id: string; + type: ProfileType; + reference: string; +} + +export interface GovCompanySeed { + id: string; + name: string; + tin: string; + email: string; + phone: string; + profiles: GovProfileSeed[]; +} + +const importExport = ( + index: number, + importerId: string, + exporterId: string, +): GovProfileSeed[] => [ + { + id: importerId, + type: ProfileType.importer, + reference: `IM-9000${index}`, + }, + { + id: exporterId, + type: ProfileType.exporter, + reference: `EX-9000${index}`, + }, +]; + +export const GOV_COMPANIES: GovCompanySeed[] = [ + { + id: "0a1b0001-0000-4000-8000-000000000001", + name: "Federal Government of Ethiopia", + tin: "0000000001", + email: "procurement@gov.et", + phone: "+251111000001", + profiles: importExport( + 1, + "0b1c0001-0000-4000-8000-000000000001", + "0b1c0001-0000-4000-8000-000000000002", + ), + }, + { + id: "0a1b0002-0000-4000-8000-000000000002", + name: "Ministry of National Defense", + tin: "0000000002", + email: "logistics@mod.gov.et", + phone: "+251111000002", + profiles: importExport( + 2, + "0b1c0002-0000-4000-8000-000000000001", + "0b1c0002-0000-4000-8000-000000000002", + ), + }, + { + id: "0a1b0003-0000-4000-8000-000000000003", + name: "Ethiopian Roads Administration", + tin: "0000000003", + email: "supply@era.gov.et", + phone: "+251111000003", + profiles: importExport( + 3, + "0b1c0003-0000-4000-8000-000000000001", + "0b1c0003-0000-4000-8000-000000000002", + ), + }, + { + id: "0a1b0004-0000-4000-8000-000000000004", + name: "Ministry of Agriculture", + tin: "0000000004", + email: "imports@moa.gov.et", + phone: "+251111000004", + profiles: importExport( + 4, + "0b1c0004-0000-4000-8000-000000000001", + "0b1c0004-0000-4000-8000-000000000002", + ), + }, + { + id: "0a1b0005-0000-4000-8000-000000000005", + name: "Ministry of Trade and Regional Integration", + tin: "0000000005", + email: "trade@motri.gov.et", + phone: "+251111000005", + profiles: importExport( + 5, + "0b1c0005-0000-4000-8000-000000000001", + "0b1c0005-0000-4000-8000-000000000002", + ), + }, + { + id: "0a1b0006-0000-4000-8000-000000000006", + name: "Ethiopian Disaster Risk Management Commission", + tin: "0000000006", + email: "relief@edrmc.gov.et", + phone: "+251111000006", + profiles: importExport( + 6, + "0b1c0006-0000-4000-8000-000000000001", + "0b1c0006-0000-4000-8000-000000000002", + ), + }, +]; + +/** Fallback entity used to backfill legacy government / null-company bookings. */ +export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0]; +export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0]; diff --git a/apps/edr-freight-api/src/seed/gov-companies.seeder.ts b/apps/edr-freight-api/src/seed/gov-companies.seeder.ts new file mode 100644 index 000000000..4fdd250e5 --- /dev/null +++ b/apps/edr-freight-api/src/seed/gov-companies.seeder.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { Company } from "../modules/companies/entities/company.entity"; +import { CompanyProfile } from "../modules/companies/entities/company-profile.entity"; +import { + GOV_COMPANIES, + GOV_COMPANY_KIND, + GOV_COMPANY_STATUS, + GOV_COMPANY_TYPE, + GOV_PROFILE_STATUS, +} from "./data/gov-companies.data"; + +/** + * Idempotently seeds the Ethiopian government entities (with importer + exporter + * profiles) that government bookings bill to. Safe to re-run — rows are keyed by + * the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched. + */ +@Injectable() +export class GovCompaniesSeeder { + private readonly logger = new Logger(GovCompaniesSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + await this.dataSource.transaction(async (manager) => { + const companyRepo = manager.getRepository(Company); + const profileRepo = manager.getRepository(CompanyProfile); + + for (const gov of GOV_COMPANIES) { + const existing = await companyRepo.findOne({ where: { id: gov.id } }); + if (!existing) { + await companyRepo.save( + companyRepo.create({ + id: gov.id, + name: gov.name, + type: GOV_COMPANY_TYPE, + kind: GOV_COMPANY_KIND, + status: GOV_COMPANY_STATUS, + tin: gov.tin, + country: "Ethiopia", + email: gov.email, + phone: gov.phone, + }), + ); + this.logger.log(`Created government company: ${gov.name}`); + } + + for (const profile of gov.profiles) { + const existingProfile = await profileRepo.findOne({ + where: { id: profile.id }, + }); + if (existingProfile) continue; + await profileRepo.save( + profileRepo.create({ + id: profile.id, + companyId: gov.id, + type: profile.type, + reference: profile.reference, + status: GOV_PROFILE_STATUS, + }), + ); + this.logger.log( + `Created ${profile.type} profile ${profile.reference} for ${gov.name}`, + ); + } + } + }); + + this.logger.log("Government companies seeded."); + } +} From db305d6fcdbc62c1908350ee5d38df5d567418c1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 27 Jun 2026 09:16:39 +0000 Subject: [PATCH 06/64] chore: add company select to backoffice booking --- .../src/pages/bookings/NewBookingPage.tsx | 81 ++++++++++++++++--- .../backoffice/src/types/customer.ts | 5 ++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index e685a3cf4..690da9998 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -180,8 +180,10 @@ export default function NewBookingPage() { const queryClient = useQueryClient(); const [isGovernment, setIsGovernment] = useState(false); - const [governmentInstitution, setGovernmentInstitution] = useState(""); const [companyId, setCompanyId] = useState(null); + // Government bookings bill to a real government company + an explicit profile. + const [govCompanyId, setGovCompanyId] = useState(null); + const [govProfileId, setGovProfileId] = useState(null); const [freightType, setFreightType] = useState("CONTAINER"); const [originYardId, setOriginYardId] = useState(null); const [destinationYardId, setDestinationYardId] = useState(null); @@ -220,6 +222,42 @@ export default function NewBookingPage() { label: c.name || c.email || c.tin || c.id, })); + // Active government companies (kind=government) the booking can bill to. + const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({ + queryKey: ["companies", "government", "active"], + queryFn: () => + customersService.list({ + page: 1, + pageSize: 1000, + kind: "government", + status: "active", + }), + enabled: isGovernment, + }); + + const govCompanies = govCompaniesPage?.items ?? []; + const govCompanyOptions = govCompanies.map((c) => ({ + value: c.id, + label: c.name || c.tin || c.id, + })); + + // Profiles (importer/exporter) of the chosen government company — the booking + // must link to one explicitly. + const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId); + const govProfileOptions = (selectedGovCompany?.companyProfiles ?? []) + .filter((p) => p.status === "active") + .map((p) => ({ + value: p.id, + label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${ + p.reference ? ` — ${p.reference}` : "" + }`, + })); + + // Reset the chosen profile when the government company changes. + useEffect(() => { + setGovProfileId(null); + }, [govCompanyId]); + // Day-level pool: fetch only the days that have a departure on the route (no // train, no capacity). The batch engine assigns the train after booking. const { data: availableDays, isLoading: daysLoading } = useQuery( @@ -306,7 +344,7 @@ export default function NewBookingPage() { Boolean(tradeDirection) && Boolean(serviceTypeId) && departureSatisfied && - (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && + (isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) && (freightType === "BULK" ? Boolean(cargoTypeId) && bulkWeight > 0 : allLinesValid); @@ -320,8 +358,8 @@ export default function NewBookingPage() { mutationFn: () => bookingsService.create({ isGovernment, - governmentInstitution: isGovernment ? governmentInstitution : undefined, - companyId: isGovernment ? undefined : companyId || undefined, + companyId: isGovernment ? govCompanyId || undefined : companyId || undefined, + companyProfileId: isGovernment ? govProfileId || undefined : undefined, freightType, contractType: "NEW", equipmentReturn, @@ -390,18 +428,37 @@ export default function NewBookingPage() { setIsGovernment(e.currentTarget.checked)} /> {isGovernment ? ( - setGovernmentInstitution(e.currentTarget.value)} - required - /> + + + ) : ( + +
+ + +
+ +
+ + not checked +
+ + + +
+

1 · Choose a booking

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

2 · Initiate payment

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

+
+ + +
+

3 · Track intent & receipt

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

Last response

+
+
+
+

Request log

+
+
+
+ + + + + From 98959c03ccf17e77669a65f0c7522c5034f5e2b7 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 16:03:00 +0300 Subject: [PATCH 27/64] Fare engine issue resolution --- .../src/modules/fare-engine/fare-engine.dto.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 5652ae91d..eb8010cd6 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -4,14 +4,14 @@ import { Type } from 'class-transformer'; import { Currency } from '@prisma/client'; // Nationality → home currency mapping (keys are uppercase for case-insensitive lookup) -export const NATIONALITY_CURRENCY_MAP: Record = { - ETHIOPIAN: Currency.ETB, - DJIBOUTIAN: Currency.DJF, +export const NATIONALITY_CURRENCY_MAP: Record = { + ETHIOPIAN: 'ETB', + DJIBOUTIAN: 'DJF', }; export function resolveCurrencyFromNationality(nationality?: string): Currency { - if (!nationality) return Currency.ETB; - return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD; + if (!nationality) return 'ETB' as Currency; + return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency; } export class FareCalculateDto { From 999753b84248c9f1ebba27c83f48194b533f08d5 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 29 Jun 2026 16:07:44 +0300 Subject: [PATCH 28/64] Update deploy.yml --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..4dbb0ddd5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,7 +170,7 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" - name: Deploy ${{ matrix.service }} run: | From 2d4608aded61fe078c0076d28bff132cf2504b01 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 29 Jun 2026 16:21:46 +0300 Subject: [PATCH 29/64] Update deploy.yml --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4dbb0ddd5..78c15cba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,7 +170,7 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" - name: Deploy ${{ matrix.service }} run: | From 0d8ed45256b62749dae9bee08037071f77d31537 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:24:01 +0000 Subject: [PATCH 30/64] fix --- ...1719667261000-AddPostPaymentCompletedColumn.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts new file mode 100644 index 000000000..b7846bac9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1719667261000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + } +} From 4c153b0e3b3f7d8d0bb7d6736f62ac654d46aeb0 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 29 Jun 2026 16:37:01 +0300 Subject: [PATCH 31/64] Update payment methods and cron job for payment cancellation --- .../migration.sql | 59 +- .../src/modules/bookings/bookings.service.ts | 26 +- .../modules/bookings/guest-booking.service.ts | 92 ++- .../fare-engine/fare-engine.service.ts | 7 +- .../modules/payments/payments.controller.ts | 26 +- .../src/modules/payments/payments.dto.ts | 6 + .../src/modules/payments/payments.service.ts | 33 +- .../src/modules/search/search.service.ts | 4 +- .../src/modules/tasks/tasks.service.ts | 137 +++-- .../src/app/booking/confirmation/page.tsx | 111 ++-- .../src/app/booking/passengers/page.tsx | 155 ++++- .../portal/src/app/booking/payment/page.tsx | 63 +- .../portal/src/app/booking/results/page.tsx | 30 +- .../portal/src/app/booking/review/page.tsx | 35 +- .../portal/src/lib/booking-store.ts | 4 + .../portal/src/lib/generate-voucher.ts | 569 ++++++++---------- 16 files changed, 793 insertions(+), 564 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql index 15b2502f5..ff5a9545e 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql @@ -1,7 +1,7 @@ -- Migration: Add Configurable Fare Management System -- Main fare configuration table -CREATE TABLE "fare_configurations" ( +CREATE TABLE IF NOT EXISTS "fare_configurations" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, @@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" ( ); -- Rate structure by nationality and coach/position -CREATE TABLE "fare_rate_rules" ( +CREATE TABLE IF NOT EXISTS "fare_rate_rules" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL' @@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" ( ); -- Configurable fare components (insurance, premiums, service charges, taxes) -CREATE TABLE "fare_components" ( +CREATE TABLE IF NOT EXISTS "fare_components" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND' @@ -52,7 +52,7 @@ CREATE TABLE "fare_components" ( ); -- Age-based pricing rules -CREATE TABLE "age_pricing_rules" ( +CREATE TABLE IF NOT EXISTS "age_pricing_rules" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "rule_name" TEXT NOT NULL, @@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" ( ); -- Audit trail for configuration changes -CREATE TABLE "fare_configuration_audit" ( +CREATE TABLE IF NOT EXISTS "fare_configuration_audit" ( "id" TEXT NOT NULL, "fare_config_id" TEXT NOT NULL, "action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED' @@ -81,27 +81,37 @@ CREATE TABLE "fare_configuration_audit" ( CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id") ); --- Foreign key constraints -ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- Foreign key constraints (idempotent) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN + ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN + ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN + ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN + ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; --- Indexes for performance -CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); -CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); -CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; +-- Indexes for performance (idempotent) +CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date"); +CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active"); +CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true; -CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); -CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); -CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); +CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position"); +CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order"); +CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age"); --- Add legacy mode flag to existing fare tables for gradual migration -ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT; -ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT; +-- Add legacy mode flag to existing fare tables for gradual migration (idempotent) +ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; +ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT; -- Add feature flag support -CREATE TABLE "system_features" ( +CREATE TABLE IF NOT EXISTS "system_features" ( "id" TEXT NOT NULL, "feature_name" TEXT NOT NULL UNIQUE, "is_enabled" BOOLEAN NOT NULL DEFAULT false, @@ -112,6 +122,7 @@ CREATE TABLE "system_features" ( CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") ); --- Insert the configurable fares feature flag -INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config") -VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}'); \ No newline at end of file +-- Insert the configurable fares feature flag (idempotent) +INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") +VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP) +ON CONFLICT ("feature_name") DO NOTHING; \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7e8947034..2ca7d5ac4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -483,8 +483,8 @@ export class BookingsService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -660,8 +660,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -854,8 +854,8 @@ export class BookingsService { } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -1060,7 +1060,7 @@ export class BookingsService { loyaltyRedemptionPoints?: number ) { const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence); + const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); @@ -1076,8 +1076,8 @@ export class BookingsService { } const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); return { baseFareMinor, @@ -1103,6 +1103,8 @@ export class BookingsService { nationality?: string, originStopSeq?: number, destStopSeq?: number, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); @@ -1149,13 +1151,13 @@ export class BookingsService { const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality); if (bestMatch) return bestMatch.baseFareMinor; - // 3. FareEngine — distance × rate-per-km from the schedule's route + // 3. FareEngine — distance × rate-per-km from the booking's actual segment stations if (schedule?.routeId) { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index cfddd77f8..6907d14a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +/** Booking cutoff: reject new bookings within this many ms of departure. */ +const BOOKING_CUTOFF_MS = 30 * 60 * 1000; + function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); @@ -74,6 +77,10 @@ export class GuestBookingService { }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); @@ -142,7 +149,9 @@ export class GuestBookingService { dto.seatClassId, segmentRoute, fullRoute, - primaryNationality + primaryNationality, + dto.originStationId, + dto.destinationStationId, ); const adultFareMinor = baseFareMinor * adultCount; @@ -160,8 +169,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -293,6 +302,10 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); + if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); @@ -350,8 +363,8 @@ export class GuestBookingService { const primaryNationality = passengersData[0]?.nationality; const [outboundBaseFare, returnBaseFare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality), - this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality), + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), ]); const paidChildrenCount = Math.max(0, childCount - 1); @@ -369,8 +382,8 @@ export class GuestBookingService { } } - const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -505,6 +518,10 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -551,11 +568,11 @@ export class GuestBookingService { this.getBaseFare(dto.scheduleId, dto.seatClassId, `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.originStationId, dto.transitStationId), this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, - primaryNationality), + primaryNationality, dto.transitStationId, dto.leg2DestinationStationId), ]); const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; @@ -569,8 +586,8 @@ export class GuestBookingService { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, combinedBase - discountMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -702,6 +719,10 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { + throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); + } + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -750,10 +771,10 @@ export class GuestBookingService { const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat), - this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), + this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), ]); const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + @@ -951,17 +972,28 @@ export class GuestBookingService { segmentRoute?: string, fullRoute?: string, nationality?: string, + originStationId?: string, + destinationStationId?: string, ): Promise { const now = new Date(); - // 1. FareRule table — explicit override rules - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId, - validFrom: { lte: now }, - OR: [{ validUntil: null }, { validUntil: { gte: now } }], - }, - }); + // 1. FareRule table — explicit override rules (same priority logic as the fare engine) + const [candidates, seatClass] = await Promise.all([ + this.prisma.fareRule.findMany({ + where: { + seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }), + this.prisma.seatClass.findUnique({ + where: { id: seatClassId }, + select: { premiumMinor: true, insuranceFeeMinor: true }, + }), + ]); + + const premiumMinor = seatClass?.premiumMinor ?? 0; + const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, @@ -982,10 +1014,11 @@ export class GuestBookingService { const match = candidates.find( (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); - if (match) return match.baseFareMinor; + // Return base fare + seat-class surcharges so the booking total matches the quoted fare + if (match) return match.baseFareMinor + premiumMinor + insuranceMinor; } - // 2. FareEngine — distance × rate-per-km from the schedule's route + // 2. FareEngine — distance × rate-per-km from the booking's actual segment stations const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, @@ -995,12 +1028,15 @@ export class GuestBookingService { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, + // Use the booking's boarding/alighting stations so the distance reflects the + // passenger's actual segment, not the full schedule route. + originStationId: originStationId ?? schedule.originStationId, + destinationStationId: destinationStationId ?? schedule.destinationStationId, seatClassId, nationality, }); - return fare.baseFarePerPassengerMinor; + // farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor + return fare.farePerPassengerMinor; } catch { // FareEngine throws if distanceKm is missing; fall through to error } diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index f2ce49791..847164551 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -284,13 +284,16 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); return fareRules.map(rule => { const seatClassId = rule.seatClassId; + const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE); + const totalMinor = rule.baseFareMinor + taxMinor; return { seatClassId, seatClassName: 'Unknown', baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, + taxMinor, + totalMinor, billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + totalInBillingCurrency: Math.round(totalMinor * exchangeRate), exchangeRate, source: 'FARE_RULE', }; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 97cf2600e..280ef2752 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -31,6 +31,7 @@ import { SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto, + BookingAmountResponseDto, } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -139,16 +140,33 @@ export class PaymentsController { @ApiOperation({ summary: "List payment systems supported by the platform", description: - "Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.", + "Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.", }) - @ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) getMethods( - @Query("currency") currency?: string, @Query("region") region?: PaymentRegionEnum, ) { - return this.service.getSupportedPaymentMethods(region, currency); + return this.service.getSupportedPaymentMethods(region); + } + + @Get("booking-amount") + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: "Get booking amount in a specific currency", + description: + "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " + + "If currency is ETB the stored amount is returned as-is (no conversion). " + + "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", + }) + @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" }) + @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD" }) + @ApiOkResponse({ type: BookingAmountResponseDto }) + getBookingAmount( + @Query("bookingId") bookingId: string, + @Query("currency") currency: string, + ) { + return this.service.getBookingAmountByCurrency(bookingId, currency); } @Get("checkout") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index c1b168138..410716b85 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -136,3 +136,9 @@ export class IntentStatusDto { @ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureMessage?: string; } + +export class BookingAmountResponseDto { + @ApiProperty({ example: 'booking-uuid' }) booking_id: string; + @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string; + @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 93bae88d3..fc6262bbc 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -491,7 +491,7 @@ export class PaymentsService { }); } - getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) { + getSupportedPaymentMethods(region?: PaymentRegionEnum) { return this.prisma.paymentMethod.findMany({ where: { enabled: true, @@ -505,12 +505,41 @@ export class PaymentsService { }, } : {}), - ...(currency ? { currency: currency.toUpperCase() } : {}), }, orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } + async getBookingAmountByCurrency( + bookingId: string, + currency: string, + ): Promise<{ booking_id: string; currency: string; amount: number }> { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { id: true, totalMinor: true }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + + const requestedCurrency = currency.toUpperCase(); + const amountInETB = booking.totalMinor / 100; + + if (requestedCurrency === 'ETB') { + return { booking_id: bookingId, currency: 'ETB', amount: amountInETB }; + } + + const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ + where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any }, + orderBy: { effectiveDate: 'desc' }, + }); + if (!exchangeRate) { + throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`); + } + + const rate = Number(exchangeRate.rate); + const converted = parseFloat((amountInETB * rate).toFixed(2)); + return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; + } + /** * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 3a7b02682..e14db6b0a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -474,8 +474,8 @@ export class SearchService { } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = Math.round(totalBaseFareMinor * 0.05); - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + const taxesMinor = 0; + const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); const displayTotalMinor = displayCurrency !== Currency.ETB diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 70009d1d8..bc52cc613 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -3,12 +3,19 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; -/** Minutes before departure at which each action fires. */ -const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS -const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking +/** Maximum time (hours) a passenger has to pay after booking. */ +const MAX_PAYMENT_HOURS = 2; +/** Minutes before departure: cutoff for new bookings and payment deadline. */ +const CUTOFF_MINUTES = 30; -/** Half-width of the reminder detection window (cron runs every 2 min). */ -const REMINDER_WINDOW_MINUTES = 2; +/** + * payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + */ +function computePaymentDeadline(createdAt: Date, departureAt: Date): Date { + const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000); + return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; +} function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { @@ -28,66 +35,72 @@ export class TasksService { ) {} // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: advance TrainSchedule statuses (departure / arrival). + // Every 1 min: advance TrainSchedule statuses. + // + // SCHEDULED → BOARDING when departure ≤ 30 min away (closed to new bookings) + // BOARDING → EN_ROUTE at actual departure + // EN_ROUTE → ARRIVED at arrival time // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async syncScheduleStatuses() { const now = new Date(); + const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); - const [departed, arrived] = await Promise.all([ + const [boarding, departed, arrived] = await Promise.all([ this.prisma.trainSchedule.updateMany({ - where: { status: 'SCHEDULED', departureAt: { lte: now } }, + where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } }, + data: { status: 'BOARDING' }, + }), + this.prisma.trainSchedule.updateMany({ + where: { status: 'BOARDING', departureAt: { lte: now } }, data: { status: 'EN_ROUTE' }, }), this.prisma.trainSchedule.updateMany({ - where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, + where: { status: 'EN_ROUTE', arrivalAt: { lte: now } }, data: { status: 'ARRIVED' }, }), ]); - if (departed.count > 0 || arrived.count > 0) { + if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) { this.logger.log( - `Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, + `Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, ); } } // ───────────────────────────────────────────────────────────────────────── - // Every 2 min: payment deadline enforcement. + // Every 1 min: payment deadline enforcement. // - // • 3 h before departure → send one SMS reminder to complete payment. - // • 2 h before departure → cancel booking if payment is still pending - // and notify the passenger by SMS. + // Reminder — sent once at the midpoint of the booking's payment window: + // reminder_at = booking_time + total_window / 2 // - // Example: train departs 08:00 - // 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled") - // 06:00 → booking auto-cancelled, cancellation SMS sent + // Cancel — when now ≥ payment_deadline + // payment_deadline = MIN(booking_time + 2h, departure_time - 30min) + // + // Examples (departure 10:00, cutoff 9:30): + // Booked 8:00 → deadline 9:30, window 1.5h, reminder at 8:45 + // Booked 9:00 → deadline 9:30, window 30min, reminder at 9:15 // ───────────────────────────────────────────────────────────────────────── - @Cron('*/2 * * * *') + @Cron('*/1 * * * *') async enforcePaymentDeadlines() { const now = new Date(); - await Promise.all([ this.sendPaymentReminders(now), this.cancelExpiredPendingBookings(now), ]); } - // ── 3-hour reminder ─────────────────────────────────────────────────────── + // ── Send reminder at the midpoint of each booking's payment window ──────── private async sendPaymentReminders(now: Date) { - // Narrow 4-minute window (±2 min around the 3-hour mark) so each booking - // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. - const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000; - const reminderMs = REMINDER_MINUTES * 60 * 1000; - - const windowStart = new Date(now.getTime() + reminderMs - windowMs); - const windowEnd = new Date(now.getTime() + reminderMs + windowMs); + // Only look at bookings created within the last 3 h with a future departure. + const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); const bookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', paymentReminderSentAt: null, - schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, + createdAt: { gte: threeHoursAgo }, + schedule: { departureAt: { gte: now } }, } as any, include: { schedule: { @@ -101,15 +114,28 @@ export class TasksService { for (const booking of bookings) { try { - const dep = booking.schedule.departureAt as Date; - const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); - const origin = booking.schedule.originStation?.name ?? ''; - const dest = booking.schedule.destinationStation?.name ?? ''; + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); + + // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately + if (totalWindowMs < 2 * 60 * 1000) continue; + + // Remind once, at the midpoint of the total payment window + const reminderAt = new Date(createdAt.getTime() + totalWindowMs / 2); + if (now < reminderAt) continue; + + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + const remainingMs = Math.max(0, paymentDeadline.getTime() - now.getTime()); + const remainingMin = Math.round(remainingMs / 60_000); const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` + - `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`; + `Complete payment within ${remainingMin} minute(s) (by ${fmtTime(paymentDeadline)}) ` + + `or your booking will be cancelled.`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); @@ -121,7 +147,8 @@ export class TasksService { }); this.logger.log( - `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`, + `Payment reminder sent: ${booking.bookingRef} ` + + `(deadline ${fmtTime(paymentDeadline)}, ${remainingMin} min remaining)`, ); } catch (err) { this.logger.error( @@ -131,14 +158,22 @@ export class TasksService { } } - // ── 2-hour auto-cancel ──────────────────────────────────────────────────── + // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // Deadline is reached when either branch of the MIN is in the past: + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + 30min → departure within 30 min const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', - schedule: { departureAt: { lte: cutoff } }, + OR: [ + { createdAt: { lte: twoHoursAgo } }, + { schedule: { departureAt: { lte: departureCutoff } } }, + ], }, include: { schedule: { @@ -151,8 +186,16 @@ export class TasksService { }, }); + let cancelledCount = 0; + for (const booking of expiredBookings) { try { + // Re-verify exact deadline to avoid racing with a concurrent payment confirmation + const createdAt = booking.createdAt as Date; + const dep = booking.schedule.departureAt as Date; + const paymentDeadline = computePaymentDeadline(createdAt, dep); + if (now < paymentDeadline) continue; + // 1. Release held seats (Journey rows are the occupancy source of truth) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); @@ -161,12 +204,12 @@ export class TasksService { data: { bookingId: booking.id, cancelledBy: 'SYSTEM', - reason: 'Payment not completed before departure deadline', + reason: 'Payment not completed before deadline', refundAmount: 0, refundMethod: booking.paymentIntent?.method ?? 'NONE', refundStatus: 'NOT_APPLICABLE', }, - }).catch(() => null); // booking may already have a cancellation record + }).catch(() => null); // 3. Mark cancelled await this.prisma.booking.update({ @@ -175,22 +218,20 @@ export class TasksService { }); // 4. Notify passenger - const dep = booking.schedule.departureAt as Date; const origin = booking.schedule.originStation?.name ?? ''; const dest = booking.schedule.destinationStation?.name ?? ''; const message = `EDR: Your booking ${booking.bookingRef} ` + `(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` + - `because payment was not completed before the deadline.`; + `because payment was not completed before the deadline (${fmtTime(paymentDeadline)}).`; if (booking.contactPhone) { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); } - this.logger.log( - `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, - ); + this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + cancelledCount++; } catch (err) { this.logger.error( `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, @@ -198,8 +239,8 @@ export class TasksService { } } - if (expiredBookings.length > 0) { - this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); + if (cancelledCount > 0) { + this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`); } } } diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 29af2b29b..8c3479f83 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; -import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; type BookingWithTicket = { @@ -77,54 +76,71 @@ export default function ConfirmationPage() { }; const handleDownloadVoucher = async () => { - if (!_booking || !pnr) { + if (!pnr) { alert('Booking data not available. Please try again.'); return; } + if (!passengers.length) { + alert('No passenger data found.'); + return; + } setIsGeneratingVoucher(true); try { - console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); - - const { generateVoucherPDF } = await import('@/lib/generate-voucher'); - - const voucherData = { - bookingRef: pnr, - status: _booking.status || 'CONFIRMED', - passengers: passengers.map(p => ({ - fullName: p.name, - category: 'ADULT', - seat: p.seatNumber ? { - number: p.seatNumber, - coach: 'N/A', - seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', - } : undefined, - })), - schedule: { - trainNumber: selectedSchedule?.trainNumber || 'N/A', - trainName: 'EDR Express', - origin: { - name: selectedSchedule?.origin || 'Origin', - code: 'ORG', - city: selectedSchedule?.origin || 'Origin', - }, - destination: { - name: selectedSchedule?.destination || 'Destination', - code: 'DST', - city: selectedSchedule?.destination || 'Destination', - }, - departureAt: selectedSchedule?.departureTime || new Date().toISOString(), - arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(), - }, - totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), - currency: 'ETB', - bookingType: 'ONE_WAY', - createdAt: new Date().toISOString(), + const { generatePassengerVoucherPDF } = await import('@/lib/generate-voucher'); + + const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; + const totalFare = _booking?.totalMinor + || passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0); + const farePerPassenger = Math.round(totalFare / passengers.length); + const createdAt = _booking?.createdAt || new Date().toISOString(); + const status = _booking?.status || 'CONFIRMED'; + + const outbound = { + trainNumber: activeSchedule?.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: activeSchedule?.origin || 'Origin', code: 'ORG', city: activeSchedule?.origin || 'Origin' }, + destination: { name: activeSchedule?.destination || 'Destination', code: 'DST', city: activeSchedule?.destination || 'Destination' }, + departureAt: activeSchedule?.departureTime || new Date().toISOString(), + arrivalAt: activeSchedule?.arrivalTime || new Date().toISOString(), + seatClass: activeSchedule?.selectedSeatClassName, }; - console.log('📄 Voucher data prepared:', voucherData); - await generateVoucherPDF(voucherData); - console.log('✅ Voucher generated successfully'); + const inbound = inboundSchedule ? { + trainNumber: inboundSchedule.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { name: inboundSchedule.origin, code: 'ORG', city: inboundSchedule.origin }, + destination: { name: inboundSchedule.destination, code: 'DST', city: inboundSchedule.destination }, + departureAt: inboundSchedule.departureTime || new Date().toISOString(), + arrivalAt: inboundSchedule.arrivalTime || new Date().toISOString(), + seatClass: inboundSchedule.selectedSeatClassName, + } : undefined; + + for (let i = 0; i < passengers.length; i++) { + const p = passengers[i]; + const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`; + + await generatePassengerVoucherPDF({ + bookingRef: pnr, + ticketNumber, + passengerName: p.name || `Passenger ${i + 1}`, + dateOfBirth: p.dateOfBirth, + nationality: p.nationality, + seatNumber: p.seatNumber, + outboundSeatNumber: (p as any).outboundSeatNumber, + inboundSeatNumber: (p as any).inboundSeatNumber, + status, + outboundSchedule: outbound, + inboundSchedule: inbound, + isRoundTrip, + fareMinor: farePerPassenger, + currency: 'ETB', + createdAt, + }); + + // brief pause between downloads so browsers don't block them + if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); + } } catch (error) { console.error('❌ Failed to generate voucher:', error); alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -191,17 +207,9 @@ export default function ConfirmationPage() { - {/* Trip Summary with QR Code */} + {/* Trip Details */}
-
- {/* QR Code Section */} -
- -

Scan at gate

-
- - {/* Trip Details */} -
+
@@ -302,7 +310,6 @@ export default function ConfirmationPage() {
)} -
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 92b82d2cb..b38234732 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -332,12 +332,135 @@ function DobPickerModal({ ); } +// ─── phone validation ───────────────────────────────────────────────────────── + +type PhoneNat = 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; + +const PHONE_PRESETS: Record = { + ETHIOPIAN: { flag: '🇪🇹', code: '+251', example: '912345678', hint: '+251912345678 or 0912345678' }, + DJIBOUTIAN: { flag: '🇩🇯', code: '+253', example: '77123456', hint: '+25377123456' }, + OTHER: { flag: '🌐', code: '+', example: '14155552671', hint: 'International: +[country code][number]' }, +}; + +function getPhoneNat(nationality: string): PhoneNat { + const n = (nationality || '').toUpperCase(); + if (n === 'ETHIOPIAN') return 'ETHIOPIAN'; + if (n === 'DJIBOUTIAN') return 'DJIBOUTIAN'; + return 'OTHER'; +} + +function validatePhone(phone: string, nationality: string): string | null { + const normalized = (phone || '').replace(/[\s\-().]/g, ''); + if (!normalized) return 'Phone number is required'; + const nat = getPhoneNat(nationality); + if (nat === 'ETHIOPIAN') { + if (/^(\+251\d{9}|09\d{8})$/.test(normalized)) return null; + return 'Invalid Ethiopian phone number (e.g., +251912345678 or 0912345678)'; + } + if (nat === 'DJIBOUTIAN') { + if (/^\+253\d{8}$/.test(normalized)) return null; + return 'Invalid Djiboutian phone number (e.g., +25377123456)'; + } + if (/^\+[1-9]\d{7,14}$/.test(normalized)) return null; + return 'Invalid international phone number (e.g., +14155552671)'; +} + +function stripPhonePrefix(stored: string, nat: PhoneNat): string { + const code = PHONE_PRESETS[nat].code; + if (nat !== 'OTHER' && stored.startsWith(code)) return stored.slice(code.length); + if (nat === 'OTHER' && stored.startsWith('+')) return stored.slice(1); + return stored; +} + +function buildFullNumber(localInput: string, nat: PhoneNat): string { + const stripped = localInput.replace(/[\s\-().]/g, ''); + if (!stripped) return stripped; + if (nat === 'ETHIOPIAN') { + if (stripped.startsWith('+') || stripped.startsWith('0')) return stripped; + return '+251' + stripped; + } + if (nat === 'DJIBOUTIAN') { + if (stripped.startsWith('+')) return stripped; + return '+253' + stripped; + } + return stripped.startsWith('+') ? stripped : '+' + stripped; +} + +function PhoneInput({ + nationality, + storedValue, + onInterimChange, + onNormalized, + error, +}: { + nationality: string; + storedValue: string; + onInterimChange: (full: string) => void; + onNormalized: (full: string) => void; + error?: string; +}) { + const nat = getPhoneNat(nationality); + const preset = PHONE_PRESETS[nat]; + const [localInput, setLocalInput] = useState(() => stripPhonePrefix(storedValue || '', nat)); + const prevStoredRef = useRef(storedValue); + + useEffect(() => { + if (storedValue !== prevStoredRef.current) { + prevStoredRef.current = storedValue; + setLocalInput(stripPhonePrefix(storedValue || '', nat)); + } + }, [storedValue, nat]); + + const handleChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + setLocalInput(raw); + onInterimChange(buildFullNumber(raw, nat)); + }; + + const handleBlur = () => { + const full = buildFullNumber(localInput, nat); + setLocalInput(stripPhonePrefix(full, nat)); + onNormalized(full); + }; + + return ( +
+
+
+ {preset.flag} + {preset.code} +
+ +
+ {error ? ( +

{error}

+ ) : ( +

Format: {preset.hint}

+ )} +
+ ); +} + +// ─── passenger zod schema ────────────────────────────────────────────────────── + const passengerSchema = z.object({ name: z.string().min(2, 'Full name is required (min 2 characters)'), dateOfBirth: z.string().min(1, 'Date of birth is required'), gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), - phone: z.string().min(1, 'Phone number is required'), + phone: z.string(), email: z.string().optional(), nationalId: z.string().optional(), passportNumber: z.string().optional(), @@ -358,6 +481,10 @@ const passengerSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] }); } } + const phoneError = validatePhone(data.phone, data.nationality); + if (phoneError) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['phone'] }); + } const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; if (isNonEthiopian) { if (!data.passportNumber || data.passportNumber.trim().length === 0) { @@ -769,14 +896,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} @@ -850,14 +976,13 @@ export default function PassengersPage() { {/* Phone */}
- setValue(`passengers.${index}.phone`, v)} + onNormalized={(v) => setValue(`passengers.${index}.phone`, v, { shouldValidate: true })} + error={errors.passengers?.[index]?.phone?.message} /> - {errors.passengers?.[index]?.phone && ( -

{errors.passengers[index]?.phone?.message}

- )}
{/* Email */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 6527a88cd..989516839 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -23,23 +23,19 @@ const getIconForMethod = (methodId: string) => { return Smartphone; }; -const NATIONALITY_TO_CURRENCY: Record = { - ETHIOPIAN: 'ETB', - DJIBOUTIAN: 'DJF', -}; export default function PaymentPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); + const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const displayCurrency: 'ETB' | 'DJF' | 'USD' = - NATIONALITY_TO_CURRENCY[searchCriteria?.nationality?.toUpperCase() ?? ''] ?? 'USD'; + const displayCurrency = 'ETB' as const; // Keep payment store in sync so the mutation picks up the right currency. useEffect(() => { @@ -54,7 +50,22 @@ export default function PaymentPage() { }, }); - // Calculate total amount + // Fetch actual booking amount from API when a payment method is selected + const amountCurrency = selectedMethodCurrency || displayCurrency; + + const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({ + queryKey: ['bookingAmount', bookingId, amountCurrency, selectedMethod], + queryFn: async () => { + const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`; + console.log('[BookingAmount] Request:', { url, bookingId, currency: amountCurrency, selectedMethod }); + const response: any = await apiClient.get(url); + console.log('[BookingAmount] Response:', response); + return response; + }, + enabled: !!selectedMethod && !!bookingId, + }); + + // Fallback: estimate from local store while API hasn't responded yet const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce( (sum) => sum + (outboundSchedule.baseFareAdult || 0), 0, @@ -69,8 +80,12 @@ export default function PaymentPage() { (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); - - const totalAmount = baseFare; + + // API returns amount in major units (e.g. 11602.5 DJF); convert to minor for display consistency + const totalAmount = bookingAmountData != null + ? Math.round(bookingAmountData.amount * 100) + : baseFare; + const confirmedCurrency = bookingAmountData?.currency || amountCurrency; const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -248,7 +263,12 @@ export default function PaymentPage() {
Total - {displayCurrency} {(totalAmount / 100).toFixed(2)} + + {loadingAmount && ( + + )} + {confirmedCurrency} {(totalAmount / 100).toFixed(2)} +
@@ -259,15 +279,19 @@ export default function PaymentPage() { )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 10eab03e0..2f053d98c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -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() {
{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 (
- {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} + {(cls.baseFareMinor / 100).toFixed(2)} - {cls.displayCurrency ?? coachCurrency} + {coachCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 655f0a7c6..67422ad69 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -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 = { 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) { diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 4aaef69a1..93262f5b8 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -54,6 +54,10 @@ export interface SelectedSchedule { displayCurrency: string; selectedSeatClass?: string; selectedSeatClassName?: string; + seatClassName?: string; + selectedCoachTypeId?: string; + selectedCoachTypeCode?: string; + selectedCoachTypeName?: string; } export interface SeatHold { diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index f05db069b..37dfa5ce9 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -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 { 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((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 => { + 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 => { + 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)); + } }; From f7f0f6aef3b60b5dc3ecd4ddcbf9b2de187d3304 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 13:45:35 +0000 Subject: [PATCH 32/64] fix: update the invoice generation to the new booking creation --- .../src/modules/bookings/bookings.module.ts | 2 +- .../contracts/contract-booking.service.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2e968309f..1a12a5e6f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -86,6 +86,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService], + exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], }) export class BookingsModule {} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 808fd8a63..b077930b7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; @@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult { */ @Injectable() export class ContractBookingService { + private readonly logger = new Logger(ContractBookingService.name); + constructor( private readonly contractsRepository: ContractsRepository, private readonly bookingsRepository: BookingsRepository, @@ -52,6 +56,7 @@ export class ContractBookingService { private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, + private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, ) {} @@ -199,6 +204,22 @@ export class ContractBookingService { } const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + + // Contract bookings are born past the billable gate (the contract is already + // executed), so the invoice is generated here — they never pass through the + // legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings. + // Idempotent and non-blocking: a billing hiccup must not undo the booking. + // Skips silently when unbillable (no company / no priced amount). + await this.invoiceService + .ensureInvoiceForBooking(result ?? booking) + .catch((err) => + this.logger.error( + `Failed to generate invoice for contract booking ${booking.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + return { booking: result ?? booking, warnings }; } From f2826f2b9b05473888d763b364da71ee883f72ed Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:49:01 +0000 Subject: [PATCH 33/64] fix --- apps/edr-freight-api/Dockerfile | 2 +- apps/edr-freight-api/package.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b0850737b..a9965c74a 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 -CMD ["node", "dist/main.js"] +CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 27737c84c..df84d0c35 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -29,7 +29,8 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js" + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" }, "dependencies": { "@edr/api-common": "workspace:*", From 8b6b6ec7375f90f9a8b43b949056568762440dc8 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:52:54 +0000 Subject: [PATCH 34/64] fix --- ...667261000-AddPostPaymentCompletedColumn.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts index b7846bac9..c755d6356 100644 --- a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts +++ b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts @@ -1,15 +1,51 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { name = 'AddPostPaymentCompletedColumn1719667261000'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); - await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.first_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } + + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.last_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); - await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + } + + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + } } } From 69955bc0e620606a5d5467a23d232d444bfa4879 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 13:53:32 +0000 Subject: [PATCH 35/64] feat: setup the invoice backend --- .../src/modules/billing/billing.module.ts | 5 +- .../modules/billing/billing.service.spec.ts | 5 ++ .../src/modules/billing/billing.service.ts | 69 +++++++++++++++++++ .../modules/billing/dto/pay-invoice.dto.ts | 30 ++++++++ .../billing/portal-billing.controller.ts | 60 ++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts create mode 100644 apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 1ed135bbb..551fae6bf 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -2,19 +2,22 @@ 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, InvoiceLine]), forwardRef(() => PaymentModule), + CompaniesModule, ], - controllers: [BillingController], + controllers: [BillingController, PortalBillingController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 907aeb76d..0e6d97de0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -75,6 +75,7 @@ describe("BillingService.generateInvoice", () => { {} as never, events as never, {} as never, // payment + {} as never, // companies ); }); @@ -132,6 +133,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, events as never, {} as never, // payment + {} as never, // companies ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -168,6 +170,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, events as never, {} as never, // payment + {} as never, // companies ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -196,6 +199,7 @@ describe("BillingService.settlePayable", () => { {} as never, events as never, {} as never, // payment + {} as never, // companies ); const settled = await service.settlePayable( @@ -229,6 +233,7 @@ describe("BillingService.settlePayable", () => { {} as never, events as never, {} as never, // payment + {} as never, // companies ); const settled = await service.settlePayable( diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 1d0473c7e..01b057a76 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -9,6 +9,16 @@ 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; @@ -84,6 +94,7 @@ export class BillingService { private readonly events: EventEmitter2, @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, + private readonly companies: CompaniesService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -104,6 +115,64 @@ export class BillingService { return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; } + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── + + /** Resolve the customer's company id from their IAM user id (null if none). */ + async resolveCompanyId(userId: string): Promise { + try { + const { company } = await this.companies.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** Every invoice billed to a company, newest first, with billing relations. */ + findByCompany(companyId: string): Promise { + return this.invoices.findAll({ + where: { companyId }, + relations: { company: true, companyProfile: true }, + order: { createdAt: "DESC" }, + }); + } + + /** Invoices for the signed-in customer; empty when they have no company. */ + async findForUser(userId: string): Promise { + const companyId = await this.resolveCompanyId(userId); + return companyId ? this.findByCompany(companyId) : []; + } + + /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + async findByIdForUser( + id: string, + userId: string, + ): Promise { + const companyId = await this.resolveCompanyId(userId); + const invoice = await this.findById(id); + if (!companyId || invoice.companyId !== companyId) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + /** + * Initiate gateway payment for one of the customer's own invoices. Verifies + * ownership, then charges whichever open invoice the source currently has + * (see {@link payInvoice}). + */ + async payInvoiceForUser( + id: string, + userId: string, + opts: PayInvoiceOptions = {}, + ): Promise { + const invoice = await this.findByIdForUser(id, userId); + return this.payInvoice( + invoice.source as Freight.InvoiceSource, + invoice.sourceId, + opts, + ); + } + // ── Generation ─────────────────────────────────────────────────────────────── /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts new file mode 100644 index 000000000..c29160ab7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +/** Gateway options for paying an invoice from the customer portal. */ +export class PayInvoiceDto { + @ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." }) + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: "web" | "mobile"; + + @ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." }) + @IsOptional() + @IsString() + payerAccount?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on success." }) + @IsOptional() + @IsString() + returnUrl?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on failure." }) + @IsOptional() + @IsString() + failureUrl?: string; +} diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts new file mode 100644 index 000000000..5a007c320 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; + +import { + type AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { BillingService } from "./billing.service"; +import { PayInvoiceDto } from "./dto/pay-invoice.dto"; + +/** + * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, + * org-wide), every route here is force-scoped to the signed-in customer's + * company — they only ever see and pay their own invoices. + */ +@ApiTags("billing") +@ApiBearerAuth() +@Controller("billing") +export class PortalBillingController { + constructor(private readonly billingService: BillingService) {} + + @Get("my-invoices") + @ApiOperation({ summary: "List the signed-in customer's invoices" }) + findMine(@CurrentUser() user: AuthUserPayload) { + return this.billingService.findForUser(resolveAuthUserId(user)); + } + + @Get("my-invoices/:id") + @ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" }) + findMineById( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); + } + + @Post("my-invoices/:id/pay") + @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) + pay( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: PayInvoiceDto, + ) { + return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), { + method: dto.method, + platform: dto.platform ?? "web", + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } +} From 6d40d185d6315dc345ec7c43263b188c7a4b8260 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 13:54:14 +0000 Subject: [PATCH 36/64] feat: setup the invoice page in the portal --- apps/edr-freight-web/portal/src/App.tsx | 8 +- .../portal/src/constants/URLS.ts | 6 + .../portal/src/constants/apiConfig.ts | 4 +- .../portal/src/lib/currency.ts | 23 + .../portal/src/lib/currentCustomer.ts | 20 - .../src/pages/MyPortalPage/MyPortalPage.tsx | 4 +- .../components/FreightVolumeSection.tsx | 4 +- .../components/InvoicesSection.tsx | 99 ++-- .../MyPortalPage/components/StatsSection.tsx | 2 +- .../src/pages/MyPortalPage/constants.ts | 15 +- .../portal/src/pages/MyPortalPage/hooks.ts | 14 +- .../portal/src/pages/billing/BillingPage.tsx | 382 ------------- .../src/pages/billing/DeleteInvoiceDialog.tsx | 63 -- .../src/pages/billing/InvoiceDetailPage.tsx | 247 ++++++++ .../portal/src/pages/billing/InvoicesList.tsx | 537 ++++++++++++++++++ .../src/pages/billing/NewInvoicePage.tsx | 202 ------- .../portal/src/pages/billing/invoice-ui.tsx | 60 ++ .../portal/src/pages/billing/invoices.mock.ts | 88 --- .../portal/src/services/api.ts | 25 + .../portal/src/services/invoices.service.ts | 89 +++ 20 files changed, 1058 insertions(+), 834 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/lib/currency.ts delete mode 100644 apps/edr-freight-web/portal/src/lib/currentCustomer.ts delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/billing/invoices.mock.ts create mode 100644 apps/edr-freight-web/portal/src/services/invoices.service.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 8ce1365c5..05c942290 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; -import BillingPage from "./pages/billing/BillingPage"; +import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage"; +import InvoicesList from "./pages/billing/InvoicesList"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; @@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [ icon: , }, { - label: "Billing", + label: "Invoices", href: "/billing", icon: , }, @@ -285,7 +286,8 @@ const App = () => { /> } /> } /> - } /> + } /> + } /> {/* Profile was merged into Settings — keep old links working. */} `/api/payments/intents/${bookingId}`, CHECKOUT: "/api/payments/checkout", }, + + BILLING: { + MY_INVOICES: "/api/billing/my-invoices", + MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`, + PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`, + }, }; diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 1b070d87d..a24cb4a6d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/lib/currency.ts b/apps/edr-freight-web/portal/src/lib/currency.ts new file mode 100644 index 000000000..d41b4edda --- /dev/null +++ b/apps/edr-freight-web/portal/src/lib/currency.ts @@ -0,0 +1,23 @@ +/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */ +export type Currency = string; + +const SYMBOLS: Record = { + USD: "$", + ETB: "Br", + DJF: "DJF", +}; + +/** + * Format a money amount with its currency symbol, e.g. `Br 12,500.00`. + * Unknown currency codes fall back to printing the raw code. + */ +export function formatCurrency( + amount: number, + currency: Currency = "ETB", +): string { + const symbol = SYMBOLS[currency] ?? currency; + return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} diff --git a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts b/apps/edr-freight-web/portal/src/lib/currentCustomer.ts deleted file mode 100644 index 47fc1efa6..000000000 --- a/apps/edr-freight-web/portal/src/lib/currentCustomer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { invoices, type Invoice } from "@/pages/billing/invoices.mock"; -import { customers, type Customer } from "@/pages/customers/customers.mock"; - -/** - * Mock "logged-in customer". When auth integrates, replace this with the value - * pulled from `@edr/iamui-common` / the JWT context. - */ -const CURRENT_CUSTOMER_ID = 1; - -export function getCurrentCustomer(): Customer { - return ( - customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ?? - (customers[0] as Customer) - ); -} - -export function getMyInvoices(): Invoice[] { - const me = getCurrentCustomer(); - return invoices.filter((inv) => inv.customerId === me.id); -} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index cb350916d..fccc4d98f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -1,5 +1,5 @@ -import type { Currency } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { Currency } from "@/lib/currency"; +import { formatCurrency } from "@/lib/currency"; import { Group, Grid, Select, Stack } from "@mantine/core"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx index 27b74cb6f..c84f94c35 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/FreightVolumeSection.tsx @@ -1,7 +1,7 @@ import { Box, Group, Skeleton, Text } from "@mantine/core"; import { memo } from "react"; -import type { Currency } from "@/pages/billing/invoices.mock"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { Currency } from "@/lib/currency"; +import { formatCurrency } from "@/lib/currency"; import { formatPct } from "../constants"; import { Card } from "./Card"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx index 3ebb9606f..22362c816 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/InvoicesSection.tsx @@ -1,7 +1,7 @@ -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 { Freight } from "@edr/types"; import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react"; import { memo } from "react"; import { Link } from "react-router-dom"; @@ -10,26 +10,22 @@ 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[]; } +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", + (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, ); @@ -56,14 +52,9 @@ export const InvoicesSection = memo(function InvoicesSection({ {formatCurrency(totalOutstanding || 0, "ETB")} - + - {outstandingInvoices.length || 2} invoices unpaid + {outstandingInvoices.length} invoices unpaid {invoices.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 isPaid = invoice.status === Freight.InvoiceStatus.Paid; + const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue; + const dueText = isPaid + ? "Paid" + : isOverdue + ? "Overdue" + : `Due ${new Date(invoice.dueAt).toLocaleDateString()}`; + const DueIcon = isPaid ? CheckCircle2 : Clock3; + const dueIconColor = isPaid ? cv("edr-green.5") : cv("edr-muted"); return ( {i > 0 && } - + - {invoice.number} + {invoice.invoiceNumber} - {invoice.bookingReference} + {titleCase(invoice.source)} · {titleCase(invoice.type)} - {formatCurrency(invoice.amount, invoice.currency)} + {formatCurrency( + Number(invoice.totalAmount), + invoice.currency, + )} - + {dueText} - - - {badge.label} - - + {badge && ( + + + {badge.label} + + + )} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index dae90842b..3ddd9be4d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,4 +1,4 @@ -import { formatCurrency } from "@/pages/billing/invoices.mock"; +import { formatCurrency } from "@/lib/currency"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react"; import { memo } from "react"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 70e58f77c..ea5567ab8 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -12,7 +12,7 @@ import { Wallet, type LucideIcon, } from "lucide-react"; -import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; +import { Freight } from "@edr/types"; export const cv = (token: string) => { const [name, shade] = token.split("."); @@ -514,12 +514,13 @@ export const ACTION_PROPS: Record< }; export const INVOICE_BADGE: Record< - InvoiceStatus, + Freight.InvoiceStatus, { label: string; bg: string; text: string } > = { - Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, - Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, - Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, - Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, - Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, + [Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, + [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, + [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, + [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" }, }; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index 8c1bb6b04..08a3ec97e 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -1,13 +1,14 @@ import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; -import { getMyInvoices } from "@/lib/currentCustomer"; import { api } from "@/services/api"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { const { user, customer, company } = useAuth(); - const myInvoices = useMemo(() => getMyInvoices(), []); + + const invoicesQuery = useQuery(api.invoices.listMy.queryOptions()); + const myInvoices = invoicesQuery.data ?? []; const companyProfiles = company?.company?.companyProfiles ?? []; @@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) { ).length; const outstandingInvoices = myInvoices.filter( - (inv) => inv.status === "Sent" || inv.status === "Overdue", + (inv) => + inv.status === Freight.InvoiceStatus.Pending || + inv.status === Freight.InvoiceStatus.Overdue, ); const totalOutstanding = outstandingInvoices.reduce( - (sum, inv) => sum + inv.amount, + (sum, inv) => sum + Number(inv.totalAmount), 0, ); @@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) { bookingsQuery, dashboardQuery, contractsQuery, + invoicesQuery, allContracts, recentContracts, activeContractsCount, diff --git a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx deleted file mode 100644 index 8b7ac4557..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/BillingPage.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { useMemo, useState } from "react"; -import { - AlertCircle, - Clock, - DollarSign, - Download, - Filter, - MoreHorizontal, - Pencil, - Plus, - Receipt, - Search, - Trash2, -} from "lucide-react"; - -import Breadcrumbs from "@/components/Breadcrumbs"; -import NewInvoicePage from "./NewInvoicePage"; -import DeleteInvoiceDialog from "./DeleteInvoiceDialog"; -import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock"; -import { - DataTable, - DataTableFooter, - type ColumnDef, - usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@edr/ui-common"; - -type FilterValue = "All" | InvoiceStatus; - -const FILTERS: FilterValue[] = [ - "All", - "Draft", - "Sent", - "Paid", - "Overdue", - "Cancelled", -]; - -export default function BillingPage() { - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [filter, setFilter] = useState("All"); - const [query, setQuery] = useState(""); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return invoices.filter((inv) => { - if (filter !== "All" && inv.status !== filter) return false; - if (!q) return true; - return ( - inv.number.toLowerCase().includes(q) || - inv.customer.toLowerCase().includes(q) || - inv.bookingReference.toLowerCase().includes(q) - ); - }); - }, [filter, query]); - - const total = filtered.length; - const pageCount = Math.ceil(total / pagination.pageSize); - const start = pagination.pageIndex * pagination.pageSize; - const end = Math.min(start + pagination.pageSize, total); - - const paginatedData = useMemo( - () => filtered.slice(start, end), - [start, end, filtered], - ); - - const totalRevenue = invoices - .filter((inv) => inv.status === "Paid" && inv.currency === "USD") - .reduce((sum, inv) => sum + inv.amount, 0); - const outstanding = invoices - .filter( - (inv) => - (inv.status === "Sent" || inv.status === "Overdue") && - inv.currency === "USD", - ) - .reduce((sum, inv) => sum + inv.amount, 0); - const overdueCount = invoices.filter( - (inv) => inv.status === "Overdue", - ).length; - - const columns: ColumnDef<(typeof invoices)[number]>[] = [ - { - id: "invoice", - header: "Invoice", - cell: ({ row }) => { - const inv = row.original; - return ( -
-
- -
-
-

{inv.number}

-

Issued {inv.issueDate}

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

- Billing -

-

- Manage invoices, payments, and financial records. -

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

Total Revenue (USD)

-

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

-
-
- -
-
-
- - - -
-

Outstanding (USD)

-

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

-
-
- -
-
-
- - - -
-

Overdue Invoices

-

- {overdueCount} -

-
-
- -
-
-
-
- - -
- {FILTERS.map((f) => { - const isActive = f === filter; - const count = - f === "All" - ? invoices.length - : invoices.filter((inv) => inv.status === f).length; - return ( - - ); - })} -
-
- - - -
- Invoices - - Issued invoices and their payment status. - -
- - -
- - - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - -
-
-
- ); -} - -function StatusBadge({ status }: { status: InvoiceStatus }) { - const styles: Record = { - Draft: "bg-slate-100 text-slate-600", - Sent: "bg-sky-100 text-sky-700", - Paid: "bg-emerald-100 text-emerald-700", - Overdue: "bg-red-100 text-red-700", - Cancelled: "bg-amber-100 text-amber-700", - }; - - return ( - - {status} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx b/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx deleted file mode 100644 index a4e278cb1..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/DeleteInvoiceDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactNode } from "react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import { Button } from "@/components/ui/button"; - -export interface DeleteInvoiceDialogProps { - invoiceNumber: string; - onConfirm?: () => void; - children: ReactNode; -} - -export default function DeleteInvoiceDialog({ - invoiceNumber, - onConfirm, - children, -}: DeleteInvoiceDialogProps) { - return ( - - {children} - - - - - Void invoice? - - - - This will void invoice{" "} - - {invoiceNumber} - - . This action cannot be undone. - - - - - - - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx new file mode 100644 index 000000000..510c7aad7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -0,0 +1,247 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + Center, + Divider, + Group, + Loader, + Paper, + SimpleGrid, + Stack, + Table, + Text, + Title, +} from "@mantine/core"; +import { ArrowLeft, CreditCard, Info } from "lucide-react"; + +import { api } from "@/services/api"; +import { formatCurrency } from "@/lib/currency"; +import { BORDER, INK, MUTED } from "../contracts/contract-ui"; +import { + billedTo, + fmtDate, + InvoiceStatusBadge, + isPayable, + titleCase, +} from "./invoice-ui"; + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +export default function InvoiceDetailPage() { + const { id = "" } = useParams(); + const navigate = useNavigate(); + + const { data: invoice, isLoading, isError } = useQuery( + api.invoices.get.queryOptions({ input: { id } }), + ); + + const payMutation = useMutation( + api.invoices.pay.mutationOptions({ + onSuccess: (res) => { + const url = res.clientAction?.url; + if (url) window.location.href = url; + }, + }), + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !invoice) { + return ( + + + + We couldn't load this invoice. It may not exist or you may not have + access to it. + + + ); + } + + const payable = isPayable(invoice.status); + const lines = invoice.lines ?? []; + + const handlePay = () => { + const returnUrl = `${window.location.origin}/payment/success`; + const failureUrl = `${window.location.origin}/payment/failure`; + payMutation.mutate({ id, payload: { returnUrl, failureUrl } }); + }; + + return ( + + + + + {/* Header */} + + + + {invoice.invoiceNumber} + + + + {payable && ( + + )} + + + {payMutation.isError && ( + } title="Payment could not be started"> + Please try again, or contact support if the problem persists. + + )} + + {/* Summary */} + + + + + + + + + + + + + Total + + + {formatCurrency(Number(invoice.totalAmount), invoice.currency)} + + + + + {/* Line items */} + + + + Line items + + + +
+ + + Charge + Qty + Unit Rate + Amount + + + + {lines.length === 0 && ( + + +
+ + No line items on this invoice. + +
+
+
+ )} + {lines.map((line) => ( + + + + {titleCase(line.chargeType)} + + {line.description && ( + + {line.description} + + )} + + + + {Number(line.quantity)} + + + + + {formatCurrency(Number(line.unitRate), line.currency)} + + + + + {formatCurrency(Number(line.amount), line.currency)} + + + + ))} +
+
+ + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx new file mode 100644 index 000000000..e1d45857f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx @@ -0,0 +1,537 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Center, + Group, + Loader, + Paper, + Select, + Stack, + Table, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { + AlertTriangle, + ChevronLeft, + ChevronRight, + CreditCard, + Eye, + FileStack, + Inbox, + Receipt, + Search, + Wallet, + X, +} from "lucide-react"; +import { Freight } from "@edr/types"; + +import { api } from "@/services/api"; +import { formatCurrency } from "@/lib/currency"; +import { + BORDER, + GREEN, + INK, + MUTED, + StatCard, +} from "../contracts/contract-ui"; +import { + billedTo, + fmtDate, + InvoiceStatusBadge, + isPayable, + PAYABLE_STATUSES, + titleCase, +} from "./invoice-ui"; + +const PAGE_SIZES = ["10", "25", "50"]; + +export default function InvoicesList() { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data, isLoading, isError } = useQuery( + api.invoices.listMy.queryOptions(), + ); + + const all = useMemo(() => data ?? [], [data]); + + const stats = useMemo(() => { + const outstanding = all.filter((i) => + PAYABLE_STATUSES.includes(i.status), + ).length; + const overdue = all.filter( + (i) => i.status === Freight.InvoiceStatus.Overdue, + ).length; + return { outstanding, overdue, total: all.length }; + }, [all]); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + return all.filter((inv) => { + if (statusFilter && inv.status !== statusFilter) return false; + if (!q) return true; + return ( + inv.invoiceNumber.toLowerCase().includes(q) || + inv.source.toLowerCase().includes(q) || + inv.sourceId.toLowerCase().includes(q) || + billedTo(inv).toLowerCase().includes(q) + ); + }); + }, [all, query, statusFilter]); + + const total = rows.length; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const clampedIndex = Math.min(pageIndex, pageCount - 1); + const start = total === 0 ? 0 : clampedIndex * pageSize + 1; + const end = Math.min((clampedIndex + 1) * pageSize, total); + const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize); + + const resetPage = () => setPageIndex(0); + const goToPage = (i: number) => + setPageIndex(Math.max(0, Math.min(i, pageCount - 1))); + + const hasFilters = !!query || !!statusFilter; + + return ( + + + {/* Header */} + + + Invoices + + + + {/* Summary strip */} + + + + + + + {/* Search + filters */} + + + } + value={query} + onChange={(e) => { + setQuery(e.currentTarget.value); + resetPage(); + }} + radius="md" + styles={{ input: { height: 42 } }} + style={{ flex: 1, minWidth: 220, maxWidth: 380 }} + /> + { + if (!v) return; + setPageSize(Number(v)); + setPageIndex(0); + }} + radius="md" + size="xs" + comboboxProps={{ withinPortal: true }} + style={{ width: 76 }} + allowDeselect={false} + /> + + {start}–{end} of {total} + + + + + } + disabled={clampedIndex === 0} + onClick={() => goToPage(clampedIndex - 1)} + ariaLabel="Previous page" + /> + {pageNumbers(clampedIndex, pageCount).map((p, i) => + p === "…" ? ( + + … + + ) : ( + goToPage(p)} + /> + ), + )} + } + disabled={clampedIndex >= pageCount - 1} + onClick={() => goToPage(clampedIndex + 1)} + ariaLabel="Next page" + /> + + + )} + + + + ); +} + +/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */ +function pageNumbers(active: number, count: number): (number | "…")[] { + if (count <= 7) return Array.from({ length: count }, (_, i) => i); + const out: (number | "…")[] = [0]; + const lo = Math.max(1, active - 1); + const hi = Math.min(count - 2, active + 1); + if (lo > 1) out.push("…"); + for (let i = lo; i <= hi; i++) out.push(i); + if (hi < count - 2) out.push("…"); + out.push(count - 1); + return out; +} + +function PageChip({ + page, + active, + onClick, +}: { + page: number; + active: boolean; + onClick: () => void; +}) { + return ( + + {page + 1} + + ); +} + +function PagerButton({ + icon, + disabled, + onClick, + ariaLabel, +}: { + icon: React.ReactNode; + disabled: boolean; + onClick: () => void; + ariaLabel: string; +}) { + return ( + + {icon} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx b/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx deleted file mode 100644 index 3e78132cd..000000000 --- a/apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { ReactNode } from "react"; -import { Calendar, DollarSign, Hash } from "lucide-react"; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; - -import { customers } from "../customers/customers.mock"; -import { bookings } from "../bookings/bookings.mock"; -import type { Currency, InvoiceStatus } from "./invoices.mock"; - -export interface InvoiceFormData { - number?: string; - customerId?: number; - bookingReference?: string; - amount?: number; - currency?: Currency; - status?: InvoiceStatus; - issueDate?: string; - dueDate?: string; - notes?: string; -} - -export interface NewInvoicePageProps { - mode?: "create" | "edit"; - invoice?: InvoiceFormData; - children?: ReactNode; -} - -const selectClass = - "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"; - -export default function NewInvoicePage({ - mode = "create", - invoice, - children, -}: NewInvoicePageProps = {}) { - const isEdit = mode === "edit"; - const title = isEdit ? "Edit Invoice" : "New Invoice"; - const description = isEdit - ? "Update invoice details." - : "Create a new invoice for a customer booking."; - const submitLabel = isEdit ? "Save Changes" : "Create Invoice"; - - return ( - - - {children ?? } - - - - - {title} - {description} - - -
- {/* Invoice Number */} -
- -
- - -
-
- - {/* Status */} -
- - -
- - {/* Customer */} -
- - -
- - {/* Booking */} -
- - -
- - {/* Amount */} -
- -
- - -
-
- - {/* Currency */} -
- - -
- - {/* Issue Date */} -
- -
- - -
-
- - {/* Due Date */} -
- -
- - -
-
- - {/* Notes */} -
- -