From aa59e30ea789c243db21eb77e309e0ab96c749a8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 13:38:48 +0000 Subject: [PATCH 01/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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 - /> + + + ) : (
+ + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
Goods List
+ + + + + + +
1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}
Container${esc(data.containerNumber)}
Weight${esc(`${data.weight.toLocaleString()} kg`)}
+
Handover Clause
+
+ The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference, + inspection status, and release records before final physical handover. +
+
+
Officer in charge name / signature / date
+
EDR
Warehouse
Handover
+
+ ${approval?.signatureImageUrl ? `` : ''} +
${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}
+
${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}
+
+
+ +`; + } + + private extractCustomerDeliveryApproval(notes?: string | null): { + approvedAt: string; + signerDisplayName: string; + signatureImageUrl: string; + } | null { + if (!notes) return null; + const line = notes + .split(/\r?\n/) + .find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX)); + if (!line) return null; + try { + const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length)); + if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null; + return { + approvedAt: String(parsed.approvedAt), + signerDisplayName: String(parsed.signerDisplayName), + signatureImageUrl: String(parsed.signatureImageUrl), + }; + } catch { + return null; + } + } + + private stripCustomerDeliveryApproval(notes?: string | null): string | null { + if (!notes?.trim()) return null; + const lines = notes + .split(/\r?\n/) + .filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX)); + return lines.join('\n').trim() || null; + } + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { throw new BadRequestException(`Invalid transition ${from} → ${to}`); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 1671b63e8..d880a3554 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts b/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts new file mode 100644 index 000000000..21750a755 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts @@ -0,0 +1,85 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.TYPEORM_LOGGING = 'false'; + +import { AppModule } from '../app.module'; +import { deriveTradeDirection } from '../common/derive-trade-direction.util'; +import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + + try { + const dataSource = app.get(DataSource); + const inventory = app.get(WarehouseInventoryService); + + const schedules: { + id: string; + trainNumber: string | null; + originCountry: string | null; + destinationCountry: string | null; + }[] = await dataSource.query( + `SELECT ts.id, + ts.train_number AS "trainNumber", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.status = 'ARRIVED' + AND ts.deleted_at IS NULL + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST, + ts.created_at DESC`, + ); + + const importSchedules = schedules.filter( + (schedule) => + deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ) === 'IMPORT', + ); + + if (importSchedules.length === 0) { + console.log('No ARRIVED import trains found.'); + return; + } + + for (const schedule of importSchedules) { + const result = await inventory.autoUnloadArrivedBookings( + schedule.id, + 'Demo Auto Unload', + ); + console.log( + `${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`, + ); + for (const item of result.results) { + console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`); + } + } + + const queueRows = await inventory.importUnloadedQueue(); + console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`); + const byStatus = queueRows.reduce>((acc, row) => { + acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1; + return acc; + }, {}); + for (const [status, count] of Object.entries(byStatus)) { + console.log(` ${status}: ${count}`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 732de6262..ff4a34493 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -1,16 +1,30 @@ import 'reflect-metadata'; import { config } from 'dotenv'; import { resolve } from 'path'; +import { WagonStatus } from '@edr/types'; config({ path: resolve(__dirname, '../../.env') }); import { AppDataSource } from '../data-source'; +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01'; +const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const; function addHours(date: Date, hours: number): Date { return new Date(date.getTime() + hours * 60 * 60 * 1000); @@ -25,18 +39,34 @@ async function main() { const locomotiveRepo = manager.getRepository(Locomotive); const trainSetRepo = manager.getRepository(TrainSet); const scheduleRepo = manager.getRepository(TrainSchedule); + const wagonTypeRepo = manager.getRepository(WagonType); + const wagonRepo = manager.getRepository(Wagon); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const serviceTypeRepo = manager.getRepository(ServiceType); + const containerTypeRepo = manager.getRepository(ContainerType); + const companyRepo = manager.getRepository(Company); + const bookingRepo = manager.getRepository(Booking); + const bookingContainerRepo = manager.getRepository(BookingContainer); + const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); + const importOperationRepo = manager.getRepository(ImportDjiboutiOperation); const negad = (await yardRepo.findOne({ where: { code: 'NEGAD' } })) ?? (await yardRepo.save( yardRepo.create({ code: 'NEGAD', - label: 'Negad', + label: 'Negad / Nagad', country: 'Djibouti', isActive: true, displayOrder: 5, }), )); + if (negad.label !== 'Negad / Nagad') { + negad.label = 'Negad / Nagad'; + await yardRepo.save(negad); + } const indode = (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? @@ -64,6 +94,78 @@ async function main() { }), )); + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ?? + (await wagonTypeRepo.save( + wagonTypeRepo.create({ + code: 'NEGAD-FLAT', + name: 'Negad Demo Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }), + )); + + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT' } })) ?? + (await containerTypeRepo.save( + containerTypeRepo.create({ + code: '40FT', + label: '40FT', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }), + )); + + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.save( + serviceTypeRepo.create({ + code: 'RAIL_CONTAINER', + serviceName: 'Rail Container Service', + description: 'Rail container service for demo marshalling', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }), + )); + + const company = + (await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ?? + (await companyRepo.save( + companyRepo.create({ + name: 'Negad Indode Marshalling Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: 'NEGADIND01', + vatNumber: 'NEGADIND01', + fanNumber: 'NEGADINDODE00001', + country: 'Ethiopia', + address: 'Indode Dry Port', + phone: '251900000202', + email: 'negad-indode-demo@edr.local', + contactPersonName: 'Marshalling Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: 'negad-indode-demo@edr.local', + generalManagerPhone: '251900000202', + }), + )); + const now = new Date(); const departure = addHours(now, -12); const arrival = now; @@ -75,7 +177,7 @@ async function main() { locomotiveId: locomotive.id, totalWeightTons: 960, totalLengthMeters: 420, - wagonCount: 18, + wagonCount: BOOKING_REFS.length, status: 'COMPLETED', }), ); @@ -111,9 +213,169 @@ async function main() { } const saved = await scheduleRepo.save(schedule); + await trainSetRepo.update(saved.trainSetId, { + totalWeightTons: BOOKING_REFS.length * 28, + totalLengthMeters: BOOKING_REFS.length * 14, + wagonCount: BOOKING_REFS.length, + status: 'COMPLETED', + }); + + const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } }); + const existingAllocations = existingSlots.length + ? await allocationRepo.find({ + where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })), + }) + : []; + if (existingAllocations.length) { + await containerItemRepo.delete( + existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })), + ); + } + if (existingSlots.length) { + await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id }))); + await wagonRepo.update( + existingSlots.map((slot) => ({ trainSetWagonId: slot.id })), + { + trainSetWagonId: null, + currentTrainScheduleId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }, + ); + await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId }); + } + + for (const [index, reference] of BOOKING_REFS.entries()) { + const sequenceNo = index + 1; + const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`; + const weightTons = 26 + sequenceNo; + + let booking = await bookingRepo.findOne({ where: { reference } }); + if (!booking) { + booking = bookingRepo.create({ reference }); + } + Object.assign(booking, { + companyId: company.id, + originYardId: negad.id, + destinationYardId: indode.id, + serviceTypeId: serviceType.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: departure, + estimatedShipmentDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: `Negad to Indode demo container ${sequenceNo}`, + cargoTotalWeightVgm: weightTons, + priorityScore: 75 - index, + trainScheduleId: saved.id, + schedulingStatus: 'SCHEDULED', + scheduledAt: now, + wagonsRequired: 1, + }); + booking = await bookingRepo.save(booking); + + await bookingContainerRepo.delete({ bookingId: booking.id }); + const bookingContainer = await bookingContainerRepo.save( + bookingContainerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType.id, + containerNumber, + quantity: 1, + vgmPerUnitTons: weightTons, + totalVgmTons: weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }), + ); + + await scheduleBookingRepo.upsert( + { trainScheduleId: saved.id, bookingId: booking.id }, + { conflictPaths: { trainScheduleId: true, bookingId: true } }, + ); + + const wagon = await wagonRepo.save( + wagonRepo.create({ + wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`, + wagonTypeId: wagonType.id, + trainId: null, + sequenceNumber: sequenceNo, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Assigned, + currentYardId: indode.id, + notes: 'Demo wagon for Negad to Indode marshalling', + trainSetWagonId: null, + currentTrainScheduleId: saved.id, + }), + ); + + const trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + trainSetId: saved.trainSetId, + wagonTypeId: wagonType.id, + physicalWagonId: wagon.id, + sequenceNo, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: weightTons, + status: 'LOADED', + }), + ); + await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: weightTons, + loadType: 'CONTAINER', + status: 'LOADED', + confirmedAt: now, + }), + ); + + await containerItemRepo.save( + containerItemRepo.create({ + wagonBookingAllocationId: allocation.id, + bookingContainerId: bookingContainer.id, + containerId: null, + containerNumber, + containerTypeId: containerType.id, + positionOnWagon: 1, + sealNumber: `SEAL-${containerNumber}`, + grossWeightTons: weightTons, + }), + ); + } + + await importOperationRepo.upsert( + { + trainScheduleId: saved.id, + documents: {}, + gatepassGrantedAt: departure, + readyForLoadingAt: departure, + loadedOnTrainAt: departure, + departedFromDjiboutiAt: departure, + loadListGeneratedAt: now, + performedBy: 'Seed Demo', + notes: 'Seeded marshalling data for Negad to Indode arrived train', + }, + { conflictPaths: { trainScheduleId: true } }, + ); + console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`); console.log(`Schedule ID: ${saved.id}`); console.log(`Route: ${negad.code} -> ${indode.code}`); + console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`); }); } finally { await dataSource.destroy(); diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts new file mode 100644 index 000000000..2d3c47c26 --- /dev/null +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -0,0 +1,474 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { WagonStatus } from '@edr/types'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +type DemoDirection = 'IMPORT' | 'EXPORT'; + +interface DemoTrain { + trainNumber: string; + direction: DemoDirection; + status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED'; + bookingPrefix: string; + departureOffsetHours: number; +} + +const DEMO_TRAINS: DemoTrain[] = [ + { + trainNumber: 'MSH-DEMO-IMP-01', + direction: 'IMPORT', + status: 'SCHEDULED', + bookingPrefix: 'MSH-IMP-01', + departureOffsetHours: 6, + }, + { + trainNumber: 'MSH-DEMO-IMP-02', + direction: 'IMPORT', + status: 'DISPATCHED', + bookingPrefix: 'MSH-IMP-02', + departureOffsetHours: -3, + }, + { + trainNumber: 'MSH-DEMO-IMP-03', + direction: 'IMPORT', + status: 'ARRIVED', + bookingPrefix: 'MSH-IMP-03', + departureOffsetHours: -14, + }, + { + trainNumber: 'MSH-DEMO-EXP-01', + direction: 'EXPORT', + status: 'SCHEDULED', + bookingPrefix: 'MSH-EXP-01', + departureOffsetHours: 8, + }, + { + trainNumber: 'MSH-DEMO-EXP-02', + direction: 'EXPORT', + status: 'DISPATCHED', + bookingPrefix: 'MSH-EXP-02', + departureOffsetHours: -2, + }, + { + trainNumber: 'MSH-DEMO-EXP-03', + direction: 'EXPORT', + status: 'ARRIVED', + bookingPrefix: 'MSH-EXP-03', + departureOffsetHours: -12, + }, +]; + +@Injectable() +export class MarshallingDemoTrainsSeeder { + private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const wagonTypeRepo = this.dataSource.getRepository(WagonType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + + const djiboutiYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const ethiopiaYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ?? + (await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ?? + (await wagonTypeRepo.findOne({ where: { isActive: true } })); + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const warehouseYard = warehouse + ? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }) + : null; + const warehouseZone = warehouseYard + ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) + : null; + + const missing = [ + !djiboutiYard ? 'Djibouti yard' : '', + !ethiopiaYard ? 'Ethiopia yard' : '', + !serviceType ? 'service type' : '', + !wagonType ? 'wagon type' : '', + !warehouse ? 'INDODE_OPEN warehouse' : '', + !warehouseYard ? 'warehouse yard' : '', + !warehouseZone ? 'warehouse zone' : '', + ].filter(Boolean); + if (missing.length) { + this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); + return; + } + + let seeded = 0; + for (const demo of DEMO_TRAINS) { + const created = await this.seedTrain(demo, { + djiboutiYard: djiboutiYard!, + ethiopiaYard: ethiopiaYard!, + serviceType: serviceType!, + cargoType, + wagonType: wagonType!, + warehouse: warehouse!, + warehouseYard: warehouseYard!, + warehouseZone: warehouseZone!, + }); + if (created) seeded += 1; + } + + this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`); + } catch (error) { + this.logger.error( + `MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async seedTrain( + demo: DemoTrain, + refs: { + djiboutiYard: Yard; + ethiopiaYard: Yard; + serviceType: ServiceType; + cargoType: CargoType | null; + wagonType: WagonType; + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation); + const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existing) { + await this.backfillDispatchQueueInventory(demo, refs); + return false; + } + + const now = new Date(); + const departure = this.addHours(now, demo.departureOffsetHours); + const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10); + const isDispatched = demo.status === 'DISPATCHED'; + const isArrived = demo.status === 'ARRIVED'; + const hasDeparted = isDispatched || isArrived; + const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + + const locomotive = await this.ensureLocomotive(originYard.id); + const bookingWeights = [22.4, 24.8, 18.6, 20.2]; + const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); + const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; + const wagonLength = Number(refs.wagonType.lengthMeters) || 14; + const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: totalWeight, + totalLengthMeters: wagonLength * bookingWeights.length, + wagonCount: bookingWeights.length, + status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard.id, + destinationStationId: destinationYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: hasDeparted ? departure : null, + actualArrivalAt: isArrived ? arrival : null, + status: demo.status as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: demo.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }), + ); + + for (const [index, weightTons] of bookingWeights.entries()) { + const sequence = index + 1; + const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`; + const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`; + + const booking = await bookingRepo.save( + bookingRepo.create({ + reference: bookingReference, + originYardId: originYard.id, + destinationYardId: destinationYard.id, + serviceTypeId: refs.serviceType.id, + status: hasDeparted ? 'IN_TRANSIT' : 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: demo.direction, + freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER', + cargoTypeId: refs.cargoType?.id ?? null, + cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`, + cargoTotalWeightVgm: weightTons * 1000, + trainScheduleId: schedule.id, + schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED', + scheduledAt: now, + }), + ); + await this.ensureDispatchQueueInventory({ + booking, + demo, + refs, + weightKg: weightTons * 1000, + now, + }); + + const physicalWagon = await this.ensureWagon({ + wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, + wagonTypeId: refs.wagonType.id, + yardId: originYard.id, + trainScheduleId: schedule.id, + tareWeight, + capacityTons: wagonCapacity, + dispatched: hasDeparted, + }); + + const trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + trainSetId: trainSet.id, + wagonTypeId: refs.wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: sequence, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: weightTons, + status: hasDeparted ? 'DEPARTED' : 'LOADED', + }), + ); + + await this.dataSource.getRepository(Wagon).update(physicalWagon.id, { + trainSetWagonId: trainSetWagon.id, + }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: weightTons, + loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK', + status: hasDeparted ? 'DEPARTED' : 'LOADED', + confirmedAt: now, + }), + ); + + await containerItemRepo.save( + containerItemRepo.create({ + wagonBookingAllocationId: allocation.id, + containerNumber, + positionOnWagon: 1, + sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`, + chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`, + grossWeightTons: weightTons, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + if (demo.direction === 'IMPORT') { + await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted); + } + return true; + } + + private async backfillDispatchQueueInventory( + demo: DemoTrain, + refs: { + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + for (let sequence = 1; sequence <= 4; sequence++) { + const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`; + const booking = await bookingRepo.findOne({ where: { reference } }); + if (!booking) continue; + await this.ensureDispatchQueueInventory({ + booking, + demo, + refs, + weightKg: Number(booking.cargoTotalWeightVgm) || 0, + now: new Date(), + }); + } + } + + private async ensureDispatchQueueInventory(input: { + booking: Booking; + demo: DemoTrain; + refs: { + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }; + weightKg: number; + now: Date; + }): Promise { + const repo = this.dataSource.getRepository(WarehouseInventory); + const existing = await repo.findOne({ where: { bookingId: input.booking.id } }); + if (existing) return; + + const exportDispatch = input.demo.direction === 'EXPORT'; + const arrivedAt = this.addHours(input.now, -8); + const inspectedAt = this.addHours(input.now, -6); + const readyAt = this.addHours(input.now, -4); + const loadedAt = this.addHours(input.now, -2); + + await repo.save( + repo.create({ + warehouseId: input.refs.warehouse.id, + yardId: input.refs.warehouseYard.id, + zoneId: input.refs.warehouseZone.id, + bookingId: input.booking.id, + quantity: 1, + weight: input.weightKg, + status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP', + inspectionStatus: 'PASSED', + arrivedAt, + unloadedAt: exportDispatch ? null : arrivedAt, + inspectedAt, + readyForLoadingAt: exportDispatch ? readyAt : null, + loadedAt: exportDispatch ? loadedAt : null, + readyForPickupAt: exportDispatch ? null : readyAt, + notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`, + }), + ); + } + + private async ensureLocomotive(currentYardId: string): Promise { + const repo = this.dataSource.getRepository(Locomotive); + const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } }); + if (existing) return existing; + return repo.save( + repo.create({ + code: 'MSH-DEMO-LOCO', + name: 'Marshalling Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId, + }), + ); + } + + private async ensureWagon(input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + tareWeight: number; + capacityTons: number; + dispatched: boolean; + }): Promise { + const repo = this.dataSource.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + if (existing) return existing; + return repo.save( + repo.create({ + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: WagonStatus.Assigned, + notes: 'Marshalling demo seed wagon', + }), + ); + } + + private async seedImportOperation( + trainScheduleId: string, + trainNumber: string, + now: Date, + departure: Date, + dispatched: boolean, + ): Promise { + const repo = this.dataSource.getRepository(ImportDjiboutiOperation); + await repo.save( + repo.create({ + trainScheduleId, + documents: { + DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now), + PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now), + DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now), + ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now), + RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now), + }, + gatepassGrantedAt: now, + readyForLoadingAt: now, + loadedOnTrainAt: now, + departedFromDjiboutiAt: dispatched ? departure : null, + performedBy: 'Marshalling Demo Seeder', + notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow', + }), + ); + } + + private documentRecord(trainNumber: string, type: string, now: Date) { + return { + reference: `${type}-${trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Marshalling Demo Seeder', + notes: 'Seeded document for import marshalling workflow', + }; + } + + private addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ab1356a42..48b912a60 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -67,6 +67,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; +import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; @@ -198,6 +200,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ // }, ], }, + { + title: "Port & Terminal", + items: [ + { + label: "Import Operations", + href: "/dashboard/import-warehouse", + icon: , + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + ], + }, + { + label: "Export Operations", + href: "/dashboard/export-warehouse", + icon: , + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + ], + }, + ], + }, { title: "Warehouse Management", items: [ @@ -211,46 +292,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/warehouses", icon: , }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - }, { label: "Allocation & Fees", href: "/dashboard/warehouse-rules", @@ -418,6 +459,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index a8f0ca1ef..f37db855d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + const openHandoverDocument = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadHandoverDocument(item.id); + const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(response.data, filename, pdfWindow); + toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Handover document failed', + description: extractErrorMessage(error), + }); + } finally { + setBusyId(null); + } + }; + const acceptLastMile = async (item: WarehouseInventoryItem) => { const reference = item.booking?.reference; if (!reference) { @@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onInspect={setInspectItem} onFeePreview={setFeeItem} onReleaseDocument={downloadReleaseDocument} + onHandoverDocument={openHandoverDocument} onLastMile={onLastMile ? acceptLastMile : undefined} selectedIds={selected} onToggleSelect={toggleSelect} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index e2ff9fb3a..eb320d78b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,5 +1,6 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { + ActionIcon, Alert, Badge, Button, @@ -8,6 +9,7 @@ import { Loader, Modal, NumberInput, + ScrollArea, Select, Stack, Table, @@ -15,28 +17,58 @@ import { Text, Textarea, TextInput, + Tooltip, } from '@mantine/core'; -import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + ClipboardCheck, + Eye, + FileText, + History, + Info, + PackageCheck, + PackageOpen, + PackageSearch, + Send, + Search, + Train, + Truck, +} from 'lucide-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { useInventoryInquiry } from '@/hooks/useWarehouses'; import { firstMileService } from '@/services/first-mile.service'; +import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, + InventoryInquiryFilter, + InventoryInquiryResult, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, TruckEntrancePayload, + WarehouseInventoryItem, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; +import { DeliverInventoryModal } from './DeliverInventoryModal'; +import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; +import { InventoryDetailModal } from './InventoryDetailModal'; +import { InventoryHistoryModal } from './InventoryHistoryModal'; import { InventoryWorkbench } from './InventoryWorkbench'; -import { extractErrorMessage, formatDate, formatNumber } from './options'; +import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal'; +import { ReleaseOrderModal } from './ReleaseOrderModal'; +import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { openPdfBlob } from './pdf'; +import '@/components/overview/overview.css'; interface ReceiveInventoryModalProps { opened: boolean; @@ -608,6 +640,7 @@ function EligibleTab({ const [statusTab, setStatusTab] = useState('ALL'); const [truckOpen, setTruckOpen] = useState(false); const [pendingReceiveIds, setPendingReceiveIds] = useState([]); + const [receivedAt, setReceivedAt] = useState(null); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const [lockedTruckFields, setLockedTruckFields] = useState({}); const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); @@ -717,6 +750,7 @@ function EligibleTab({ setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); + setReceivedAt(null); setLockedTruckFields({}); setPackagingFreightType('MIXED'); onChanged?.(); @@ -749,6 +783,7 @@ function EligibleTab({ } const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); + setReceivedAt(new Date().toISOString()); setTruckForm(form); setLockedTruckFields(lockedFields); setPackagingFreightType(nextPackagingFreightType); @@ -807,15 +842,18 @@ function EligibleTab({ )} )} @@ -967,7 +1015,7 @@ function EligibleTab({ setTruckOpen(false)} - title="Export Truck Arrival / First Mile Receive Form" + title="Receive to Warehouse" centered size="lg" > @@ -979,6 +1027,52 @@ function EligibleTab({ : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + + + + + Booking + Customer + TIN / Phone + Container / Cargo + Qty / Package + Weight + Received at + + + + {pendingReceiveRows.map((booking) => ( + + + {booking.reference} + {booking.id.slice(0, 8)}... + + {booking.customer ?? '-'} + + + {booking.customerTin ?? '-'} + {booking.customerPhone ?? '-'} + + + + + {booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'} + {booking.freightType ?? '-'} + + + + {[ + booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null, + booking.containerPackagingType, + ].filter(Boolean).join(' / ') || '-'} + + {formatNumber(Number(booking.weight))} + {formatDate(receivedAt)} + + ))} + +
+
Booking ID Customer ID Customer Name - Container # + Container / Cargo Items Cargo Type Weight Route @@ -1466,11 +1560,11 @@ function LoadedExportTab({ } /** Assigned bookings/items for an arrived import train (read-only detail view). */ -function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { +function ImportTrainDetailTable({ train }: { train: ImportTrain }) { const { data: items = [], isLoading } = useQuery( api.warehouses.importTrainItems.queryOptions({ - input: { scheduleId }, - enabled: Boolean(scheduleId), + input: { scheduleId: train.scheduleId }, + enabled: Boolean(train.scheduleId), }), ); @@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { + Wagon Booking ID Booking Ref Customer ID @@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { {items.map((it: ImportTrainItem) => ( - + + + + {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} + + {it.bookingId.slice(0, 8)}… @@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({ {t.totalCargoes} - - {fullyUnloaded ? 'UNLOADED' : t.status} + + {t.status} {Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded @@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({ {isOpen && ( - + )} @@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({ */ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); + const qc = useQueryClient(); const { data: rows = [], isLoading } = useQuery( api.warehouses.importUnloadedQueue.queryOptions({ enabled }), ); const inspectMutation = useMutation( api.warehouses.bulkMarkInspected.mutationOptions(), ); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); + const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [viewItem, setViewItem] = useState(null); + const [historyItem, setHistoryItem] = useState(null); + const [feeItem, setFeeItem] = useState(null); + const [releaseItem, setReleaseItem] = useState(null); + const [deliverItem, setDeliverItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, }); setSelected(new Set()); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); } }; + const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem => + ({ + id: row.id, + bookingId: row.bookingId, + quantity: 1, + weight: Number(row.weight) || 0, + status: row.currentStatus, + arrivedAt: row.arrivalTime, + unloadedAt: row.arrivalTime, + inspectionStatus: row.inspectionStatus, + releaseDate: row.releaseDate, + releaseOrderReference: row.releaseOrderReference, + deliveredAt: row.deliveredAt, + booking: row.bookingId + ? { + id: row.bookingId, + reference: row.bookingReference ?? row.bookingId, + tradeDirection: 'IMPORT', + lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null, + } + : null, + }) as unknown as WarehouseInventoryItem; + + const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise) => { + setBusyId(row.id); + try { + await fn(); + toast({ title: label }); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + } catch (error) { + toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + + const openHandoverDocument = async (row: ImportUnloadedItem) => { + setBusyId(row.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadHandoverDocument(row.id); + openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + return ( @@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {r.currentStatus} - + + + setViewItem(toInventoryItem(r))}> + + + + {r.currentStatus === 'UNLOADED' && ( + + )} + {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + <> + + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && ( + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + + )} + {r.inspectionStatus === 'PASSED' && ( + + )} + + + setFeeItem(toInventoryItem(r))}> + + + + + setHistoryItem(toInventoryItem(r))}> + + + + ))} @@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { onClose={() => setInspectId(null)} inventoryId={inspectId} /> + setViewItem(null)} item={viewItem} /> + setHistoryItem(null)} item={historyItem} /> + setFeeItem(null)} + inventoryId={feeItem?.id ?? null} + /> + setReleaseItem(null)} item={releaseItem} /> + setDeliverItem(null)} item={deliverItem} /> ); } @@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) { ); } -/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ -function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { - const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); - const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT'); +type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH'; +type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking'; +type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking'; - useEffect(() => { - if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' }); - }, [opened]); +interface WarehouseQueueTab { + value: TValue; + label: string; + icon: React.ReactNode; + count?: number; +} + +interface WarehouseFlowWorkbenchProps { + direction?: WarehouseFlowDirection; + enabled?: boolean; + onChanged?: () => void; +} + +function WarehouseQueueTabs({ + value, + onChange, + tabs, +}: { + value: TValue; + onChange: (value: TValue) => void; + tabs: WarehouseQueueTab[]; +}) { + return ( + onChange((next as TValue) ?? value)} + variant="pills" + color="edr-green" + keepMounted={false} + classNames={{ list: 'ov-tablist', tab: 'ov-tab' }} + > + + + {tabs.map((tab) => { + const active = value === tab.value; + return ( + + {tab.count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + + + ); +} + +function LocateBookingTab({ enabled }: { enabled: boolean }) { + const [draft, setDraft] = useState({}); + const [applied, setApplied] = useState({}); + const [viewResult, setViewResult] = useState(null); + const hasSearch = Boolean( + applied.bookingReference || + applied.containerNumber || + applied.goodsName || + applied.cargoType || + applied.status, + ); + const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch); + + const normalizeDraft = (): InventoryInquiryFilter => ({ + bookingReference: draft.bookingReference?.trim() || undefined, + containerNumber: draft.containerNumber?.trim() || undefined, + goodsName: draft.goodsName?.trim() || undefined, + cargoType: draft.cargoType?.trim() || undefined, + status: draft.status, + }); + + const runSearch = () => setApplied(normalizeDraft()); + const reset = () => { + setDraft({}); + setApplied({}); + }; return ( - - - + + + setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={230} + /> + setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={220} + /> + { + const value = e.currentTarget.value || undefined; + setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value })); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={200} + /> +
+ Wagon Booking ID Booking Reference Customer ID @@ -115,6 +116,11 @@ function ExportTrainDetailRows({ {items.map((item: ExportTrainItem) => ( + + + {item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''} + + {item.bookingId.slice(0, 8)} @@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx new file mode 100644 index 000000000..bf086f1ce --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx @@ -0,0 +1,28 @@ +import { Button, Card } from '@mantine/core'; +import { PackageSearch } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseFlowWorkbench } from '@/components/warehouses'; + +export default function ExportWarehouseFlowPage() { + const navigate = useNavigate(); + + return ( + + } onClick={() => navigate('/dashboard/import-warehouse')}> + Import Operations + + } + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx new file mode 100644 index 000000000..2fd2f4f43 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx @@ -0,0 +1,28 @@ +import { Button, Card } from '@mantine/core'; +import { Truck } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseFlowWorkbench } from '@/components/warehouses'; + +export default function ImportWarehouseFlowPage() { + const navigate = useNavigate(); + + return ( + + } onClick={() => navigate('/dashboard/export-warehouse')}> + Export Operations + + } + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 057dd0c21..7133c47ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -1,13 +1,12 @@ import { useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { PackagePlus, Search } from 'lucide-react'; +import { PackageOpen, Search, Truck } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; import { InventoryWorkbench, - ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; import { @@ -19,13 +18,13 @@ import { import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined; const [filter, setFilter] = useState( initialStatus ? { status: initialStatus } : {}, ); const [search, setSearch] = useState(''); - const [modalOpen, setModalOpen] = useState(false); const [debouncedSearch] = useDebouncedValue(search, 300); const queryFilter = useMemo( @@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() { title="Warehouse Inventory" subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle." action={ - + + + + } /> @@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() { - - setModalOpen(false)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index a9a98a80d..83909c415 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -137,6 +137,10 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), { responseType: 'blob', }), + downloadHandoverDocument: (id: string) => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { + responseType: 'blob', + }), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index e3285b6c1..bca62a21c 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -517,6 +517,9 @@ export interface ExportTrainItem { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; itemType: 'CONTAINER' | 'CARGO'; itemId: string | null; inventoryId: string | null; @@ -566,6 +569,9 @@ export interface ImportUnloadedItem { pickupOption: string; lastMileRequested: boolean; currentStatus: string; + releaseDate: string | null; + releaseOrderReference: string | null; + deliveredAt: string | null; } export interface ImportTrainItem { @@ -573,6 +579,9 @@ export interface ImportTrainItem { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; containerNumber: string | null; cargoType: string | null; weight: number | null; diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 341d2c7c3..6e7326189 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { defineConfig } from "vitest/config"; -import { loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const streamBrowserifyPath = require.resolve("stream-browserify"); -export default defineConfig(({ mode }) => { +export default defineConfig(() => { return { plugins: [react(), tailwindcss()], resolve: { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index d115369c3..3a558c5cb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,10 +1,12 @@ import { Box, Group, Text } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; -import { CreditCard, Download } from "lucide-react"; +import { CheckCircle2, CreditCard, Download } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import type { Freight } from "@edr/types"; @@ -49,6 +51,19 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) window.location.href = redirectUrl; }, }); + const approveDeliveryMutation = useMutation({ + mutationFn: () => bookingsService.approveDelivery(booking.id), + onSuccess: (data) => { + toast.success(`Delivery approved as ${data.signerDisplayName}`); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Could not approve delivery. Please try again.", + ); + }, + }); const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never @@ -62,6 +77,10 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) : status === "SELECTED_FOR_BATCH"); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; + const canApproveDelivery = + booking.tradeDirection === "IMPORT" && + !isNegative(status) && + !["DRAFT", "DRAFT_DOCUMENTS_PENDING"].includes(status); const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isClearance = [ "AWAITING_DOCUMENTS", @@ -81,14 +100,30 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } - label="Pay now" - onClick={() => setPayModalOpen(true)} - /> + (canPay || canApproveDelivery) && ( + + {canApproveDelivery && ( + } + label={ + approveDeliveryMutation.isPending + ? "Approving..." + : "Approve Delivery" + } + disabled={approveDeliveryMutation.isPending} + onClick={() => approveDeliveryMutation.mutate()} + /> + )} + {canPay && !showCountdown && ( + } + label="Pay now" + onClick={() => setPayModalOpen(true)} + /> + )} + ) } menuActions={{ diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 6a8c76795..2d0e79a9a 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -72,6 +72,13 @@ export interface SignContractPayload { consentText?: string; } +export interface ApproveDeliveryResponse { + bookingId: string; + inventoryId: string; + approvedAt: string; + signerDisplayName: string; +} + export interface BookingListFilter { status?: string; /** Comma-separated statuses (overrides `status` when set). */ @@ -242,6 +249,13 @@ export const bookingsService = { return data.data ?? data; }, + approveDelivery: async (id: string): Promise => { + const { data } = await client.post( + `/api/warehouse-inventory/bookings/${id}/approve-delivery`, + ); + return data.data ?? data; + }, + getBookableSchedules: async ( query: Freight.BookableSchedulesQuery = {}, ): Promise => { From 9d7d08cf5dbc849285cf146b235887bca1689341 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 09:24:45 +0000 Subject: [PATCH 19/48] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 28771028e..358bf35c1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -723,6 +723,12 @@ const FirstMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , }, + { + id: "invoice", + header: "Invoice", + meta: { headerClassName, cellClassName }, + cell: () => "#345", + }, { id: "status", header: "Status", From 4be4286fbf193b9b58e3d3e0717a92588040d9fe Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 09:25:46 +0000 Subject: [PATCH 20/48] feat: rewired up the billing and payment with the booking --- .../src/modules/billing/billing.controller.ts | 2 +- .../src/modules/billing/billing.module.ts | 8 +- .../modules/billing/billing.service.spec.ts | 5 + .../src/modules/billing/billing.service.ts | 102 ++++- .../bookings/booking-invoice.service.ts | 104 ++++- .../bookings/booking-payment.controller.ts | 180 +++++++++ .../bookings/booking-payment.service.ts | 34 +- .../src/modules/bookings/bookings.module.ts | 9 +- .../src/modules/payment/payment.controller.ts | 143 +------ .../src/modules/payment/payment.module.ts | 10 +- .../src/modules/payment/payment.service.ts | 358 +++++++++--------- packages/types/src/freight/index.ts | 9 +- 12 files changed, 584 insertions(+), 380 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-payment.controller.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 e7ac41704..e954b7e1b 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, Post } from "@nestjs/common"; +import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FreightAdmin } from "../../common/booking-guards"; 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 1dd745088..1ed135bbb 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; @@ -7,9 +7,13 @@ 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"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice, InvoiceLine])], + imports: [ + TypeOrmModule.forFeature([Invoice, InvoiceLine]), + forwardRef(() => PaymentModule), + ], controllers: [BillingController], 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 72ddb0b02..907aeb76d 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 @@ -74,6 +74,7 @@ describe("BillingService.generateInvoice", () => { {} as never, {} as never, events as never, + {} as never, // payment ); }); @@ -130,6 +131,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, {} as never, events as never, + {} as never, // payment ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -165,6 +167,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, {} as never, events as never, + {} as never, // payment ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -192,6 +195,7 @@ describe("BillingService.settlePayable", () => { {} as never, {} as never, events as never, + {} as never, // payment ); const settled = await service.settlePayable( @@ -224,6 +228,7 @@ describe("BillingService.settlePayable", () => { {} as never, {} as never, events as never, + {} as never, // payment ); 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 b1e3d25bf..448ad6685 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; @@ -7,6 +7,8 @@ import { Invoice } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; /** Default invoice payment-term window, in days, used to compute `dueAt`. */ const DEFAULT_DUE_DAYS = 14; @@ -80,6 +82,8 @@ export class BillingService { private readonly invoices: InvoiceRepository, private readonly invoiceLines: InvoiceLineRepository, private readonly events: EventEmitter2, + @Inject(forwardRef(() => PaymentService)) + private readonly payment: PaymentService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -304,9 +308,9 @@ export class BillingService { /** * The invoice a gateway payment should settle for a source record, or null if * none. This is the billing document of record for "what is owed" — callers - * (e.g. `payment.service.initiatePayment`) should charge `invoice.totalAmount` - * against it rather than recomputing from the source's own total, so - * discounts/penalties/adjustments carried on the invoice are honored. + * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than + * recomputing from the source's own total, so discounts/penalties/adjustments + * carried on the invoice are honored. * * Pass `type` to select a specific invoice when a source carries several (e.g. * a booking's up-front vs final charge); omit it to settle whichever single @@ -335,10 +339,13 @@ export class BillingService { * delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial * settlement. No-op (returns null) when the source has no open invoice. * - * Type-blind by design: the payment process settles whichever invoice is due; - * any per-type reaction belongs in the `${source}.invoice.paid` handler, which - * reads `invoice.type`. Pass the caller's transaction `manager` (e.g. from - * `payment.service.finalizePaymentSuccess`) to enlist in its DB transaction. + * Type-blind by design: settles whichever invoice is due; any per-type reaction + * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`. + * Pass the caller's transaction `manager` to enlist in its DB transaction. + * + * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` + * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper + * for callers that settle by source rather than by gateway intent id. */ async settlePayable( source: Freight.InvoiceSource, @@ -378,4 +385,83 @@ export class BillingService { return this.markInvoiceAsRefunded(invoice.id, mg); } + + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── + + /** + * Charge a source's open invoice through the payment gateway. Billing is the + * single place that turns "what is owed" (the invoice) into a payment intent — + * the domain never talks to the payment service directly. Resolves the open + * invoice, opens an intent for `invoice.totalAmount`, records the intent id on + * the invoice (the settlement correlation key), and returns the client action. + * + * When the provider settles synchronously, the invoice is settled inline here — + * after the intent id is stored — so the `payment.succeeded` correlation can + * never fire before the link exists. Throws when the source has no open invoice. + */ + async payInvoice( + source: Freight.InvoiceSource, + sourceId: string, + opts: { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + } = {}, + ): Promise { + const invoice = await this.findPayable(source, sourceId); + if (!invoice) { + throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + } + + const result = await this.payment.initiate({ + referenceId: sourceId, + orderRef: invoice.invoiceNumber, + amountMinor: Math.round(Number(invoice.totalAmount)), + currency: invoice.currency, + reason: `Payment for invoice ${invoice.invoiceNumber}`, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + payerAccount: opts.payerAccount, + returnUrl: opts.returnUrl, + failureUrl: opts.failureUrl, + }); + + // Link the intent to the invoice BEFORE any settlement can correlate against it. + await this.dataSource + .getRepository(Invoice) + .update({ id: invoice.id }, { paymentId: result.intentId }); + + if (result.immediateSuccess) { + await this.settleByPaymentId( + result.intentId, + result.providerTxnId, + result.paidAt, + ); + } + + return result.response; + } + + /** + * Settle the open invoice linked to a gateway intent id, if any. Called by the + * payment service when an intent succeeds: finds the invoice linked by + * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain + * to advance on. Idempotent — no-op when no open invoice is linked (already + * settled, or settled inline by {@link payInvoice}). + */ + async settleByPaymentId( + paymentId: string, + _providerTxnId?: string, + _paidAt?: Date, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { paymentId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index daa3327dd..4eacedc13 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,6 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; +import { DataSource } from 'typeorm'; import { BillingService, @@ -9,7 +10,11 @@ import { InvoiceLineInput, } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; +import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ @@ -22,18 +27,32 @@ interface StoredPricingBreakdown { /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; +/** Setting code holding the general-contract ordering window (months). */ +const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; + /** * Owns the booking ⇄ invoice mapping — the one place that knows how a booking - * turns into invoices and which type to use. Bookings are the billable business - * entity, so they generate their own invoices directly via {@link BillingService} - * (billing stays source-agnostic). All booking-specific type branching lives here, - * at the two points it belongs: invoice creation and settlement (the paid handler). + * turns into invoices, which type to use, and how it advances when paid. Bookings + * are the billable business entity, so they generate their own invoices directly + * via {@link BillingService} (billing stays source-agnostic). All booking-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). */ @Injectable() export class BookingInvoiceService { private readonly logger = new Logger(BookingInvoiceService.name); - constructor(private readonly billing: BillingService) {} + constructor( + private readonly billing: BillingService, + private readonly bookingsRepository: BookingsRepository, + private readonly dataSource: DataSource, + private readonly dropdownSettings: DropdownSettingsService, + @Inject(forwardRef(() => FirstMileService)) + private readonly firstMile: FirstMileService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatch: BookingBatchService, + ) {} /** * Ensure the booking has its invoice, generating one from the snapshotted @@ -71,15 +90,14 @@ export class BookingInvoiceService { /** * React to a booking invoice being paid — the settlement branch point. Per-type - * reactions live here (not in the payment process): e.g. a paid up-front invoice - * may later generate a final invoice. Only PREPAID exists today. + * reactions live here (not in the payment process): each invoice type advances + * the booking its own way. Only PREPAID exists today. */ @OnEvent('booking.invoice.paid') - onBookingInvoicePaid(payload: InvoiceEventPayload): void { + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { switch (payload.type) { case Freight.InvoiceType.Prepaid: - // Full prepaid settlement — booking advancement is handled by the - // payment flow today. Final-invoice issuance would hook in here. + await this.advanceBookingOnPayment(payload.sourceId); break; default: this.logger.warn( @@ -88,6 +106,70 @@ export class BookingInvoiceService { } } + /** + * Advance a booking once its prepaid invoice settles. This is the domain + * side-effect of payment, relocated out of the payment service: a general + * contract becomes ACTIVE and opens its ordering window (it does not enter the + * train queue — nothing has been ordered yet); a normal booking becomes PAID + * and is allocated into its batch. Idempotent — no-op when already PAID. + */ + private async advanceBookingOnPayment(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`); + return; + } + if (booking.paymentStatus === 'PAID') return; + + const paidAt = new Date(); + const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; + + let contractExpiresAt: Date | null = null; + if (isGeneralContract) { + const months = await this.contractPeriodMonths(); + contractExpiresAt = new Date(paidAt); + contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); + } + + await this.dataSource.transaction(async (mg) => { + await mg.update( + Booking, + { id: bookingId }, + isGeneralContract + ? { paymentStatus: 'PAID', status: 'CONTRACT_ACTIVE', expiresAt: contractExpiresAt } + : { paymentStatus: 'PAID', status: 'PAID' }, + ); + await this.firstMile.acceptBooking(bookingId); + }); + + if (isGeneralContract) { + this.logger.log( + `General contract ${booking.reference} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, + ); + return; + } + + try { + await this.bookingBatch.ensurePaidBookingAllocated(bookingId); + } catch (err) { + this.logger.error( + `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Configured general-contract ordering window in months (defaults to 3). */ + private async contractPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode(CONTRACT_PERIOD_SETTING_CODE); + const months = Number(setting.children?.[0]?.value); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + /** Map a booking's pricing snapshot into a generic invoice request. */ private buildInput(booking: Booking): GenerateInvoiceInput | null { const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts new file mode 100644 index 000000000..ae01ebc36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts @@ -0,0 +1,180 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Query, + Res, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { Public } from "@edr/api-common"; +import { Freight } from "@edr/types"; + +import { BillingService } from "../billing/billing.service"; +import { + InitiatePaymentDto, + InitiateResponseDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "../payment/payments.dto"; + +/** + * Booking-payment entrypoints. This is the ONE place that knows a payment is for a + * booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands + * off to billing, which resolves the invoice/amount and drives the gateway. Billing + * and payment stay source-agnostic; the booking knowledge lives here, in the domain. + * Routes are unchanged (`/payments/*`) so the portal is unaffected. + */ +@ApiTags("Payment") +@Controller("payments") +export class BookingPaymentController { + constructor(private readonly billing: BillingService) { } + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment for a freight booking", + description: "Charges the booking's open invoice through the payment gateway.", + }) + @ApiOkResponse({ type: InitiateResponseDto }) + initiate(@Body() dto: InitiatePaymentDto): Promise { + return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + method: dto.method, + platform: dto.platform, + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } + + @Get("checkout") + @Public() + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") + async checkout( + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", + @Res() res: Response, + ) { + if (!bookingId) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + } + if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing or invalid query parameter: method")); + } + + try { + const result = await this.billing.payInvoice( + Freight.InvoiceSource.Booking, + bookingId, + { method, platform }, + ); + const url = + result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined; + + if (url) { + return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url)); + } + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "An unexpected error occurred"; + return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message)); + } + } + + private buildRedirectHtml(url: string): string { + const escaped = url.replace(/\"/g, """); + return ` + + + + + Redirecting to payment… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 21473eeb8..1fbe34e1f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -1,49 +1,37 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { Freight } from '@edr/types'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -import { PaymentService } from '../payment/payment.service'; -import { PaymentStatus } from '../payment/entities/payment.entity'; +import { BillingService } from '../billing/billing.service'; import { PaymentMethodTypeEnum } from '../payment/payments.dto'; export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } -const NON_TERMINAL_STATUSES: PaymentStatus[] = [ - "action-required", - "processing", - "success", -]; - @Injectable() export class BookingPaymentService { constructor( private readonly bookingsRepository: BookingsRepository, - private readonly paymentService: PaymentService, + private readonly billing: BillingService, ) { } + /** + * Start payment for a booking. The booking never touches the payment gateway + * directly — it charges its invoice through billing, which resolves the amount + * and drives the provider. Returns the provider redirect URL (empty when none). + */ async pay(bookingId: string): Promise<{ redirectUrl: string }> { const booking = await this.requireBooking(bookingId); assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']); - const existing = await this.paymentService.findBookingById(bookingId); - if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { - if (existing.clientAction) { - const action = existing.clientAction as { type?: string; url?: string }; - if (action.type === "REDIRECT" && action.url) { - return { redirectUrl: action.url }; - } - } - } - - const resp = await this.paymentService.initiatePayment({ - bookingId, + const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { method: PaymentMethodTypeEnum.TELEBIRR, - platform: "web", + platform: 'web', }); const action = resp.clientAction as { type?: string; url?: string } | undefined; return { - redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "", + redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', }; } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8318a4e7d..2e968309f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -11,8 +11,11 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { FirstMileModule } from '../first-mile/first-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; +import { BookingPaymentController } from './booking-payment.controller'; import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; @@ -35,7 +38,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @Module({ @@ -50,8 +52,9 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingReviewNote, BookingContractSignature, ]), - PaymentModule, BillingModule, + DropdownSettingsModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, @@ -66,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, PayController, BookingPaymentController], providers: [ BookingsService, BookingsRepository, diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index f1f34c3b1..b1b269665 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -1,13 +1,13 @@ import { - Body, Controller, Get, HttpStatus, Param, ParseUUIDPipe, - Post, Query, Res, + Body, + Post, } from "@nestjs/common"; import { ApiTags, @@ -20,14 +20,7 @@ import { Response } from "express"; import { Public } from "@edr/api-common"; import { BookingView, FreightAdmin } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { - InitiatePaymentDto, - InitiateResponseDto, - IntentStatusDto, - PaymentMethodTypeEnum, - PaymentPlatformDto, - RefundDto, -} from "./payments.dto"; +import { IntentStatusDto, RefundDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,16 +66,6 @@ export class PaymentController { }); } - @Post("initiate") - @ApiOperation({ - summary: "Initiate payment for a freight booking", - description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, - }) - @ApiOkResponse({ type: InitiateResponseDto }) - initiatePayment(@Body() dto: InitiatePaymentDto) { - return this.paymentService.initiatePayment(dto); - } - @Get("intents/:bookingId") @ApiOperation({ summary: "Get payment intent status for a booking" }) @ApiOkResponse({ type: IntentStatusDto }) @@ -97,54 +80,6 @@ export class PaymentController { return this.paymentService.refund(dto); } - @Get("checkout") - @Public() - @ApiOperation({ - summary: "Browser checkout redirect", - description: - "Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", - }) - @ApiQuery({ name: "bookingId", required: true }) - @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) - @ApiProduces("text/html") - async checkout( - @Query("bookingId") bookingId: string, - @Query("method") method: PaymentMethodTypeEnum, - @Query("platform") platform: PaymentPlatformDto = "web", - @Res() res: Response, - ) { - if (!bookingId) { - return res - .status(HttpStatus.BAD_REQUEST) - .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); - } - if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { - return res - .status(HttpStatus.BAD_REQUEST) - .type("html") - .send(this.buildErrorHtml("Missing or invalid query parameter: method")); - } - - try { - const result = await this.paymentService.initiatePayment({ bookingId, method, platform }); - const url = - result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined; - - if (url) { - return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url)); - } - return res - .status(HttpStatus.OK) - .type("html") - .send(this.buildStatusHtml(result.status, result.intentId)); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "An unexpected error occurred"; - return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message)); - } - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) @@ -153,76 +88,4 @@ export class PaymentController { const html = await this.paymentService.genReceiptHtml(orderId); return res.status(HttpStatus.OK).type("html").send(html); } - - private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, """); - return ` - - - - - Redirecting to payment… - - - -
-
-

Redirecting to payment provider…

-

Click here if you are not redirected

-
- - -`; - } - - private buildStatusHtml(status: string, intentId: string): string { - return ` - - - - Payment status - - - -
-
${status}
- Intent: ${intentId} -
- -`; - } - - private buildErrorHtml(message: string): string { - return ` - - - - Payment error - - - -
-
Payment could not be initiated
-

${message}

-
- -`; - } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 18e32330d..330521218 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,4 +1,4 @@ -import { DynamicModule, Module, forwardRef } from "@nestjs/common"; +import { DynamicModule, forwardRef, Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq"; @@ -13,9 +13,6 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; -import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; -import { FirstMileModule } from "../first-mile/first-mile.module"; -import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -59,10 +56,7 @@ function rabbitMQImport(): DynamicModule[] { imports: [ HttpModule.register({ timeout: 10_000 }), ConfigModule, - DropdownSettingsModule, - BillingModule, - forwardRef(() => FirstMileModule), - forwardRef(() => TrainSchedulingModule), + forwardRef(() => BillingModule), TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 5fb77f404..7beb6e8c6 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -11,6 +11,7 @@ import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; +import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; @@ -22,26 +23,46 @@ import { ProviderPaymentStatus, } from "@edr/payment-providers"; import { - Freight, PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, ProviderMethod, } from "@edr/types"; -import { BillingService } from "../billing/billing.service"; import { - InitiatePaymentDto, InitiateResponseDto, IntentStatusDto, + PaymentPlatformDto, RefundDto, } from "./payments.dto"; -import { BookingBatchService } from "../train-scheduling/booking-batch.service"; -import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; -import { FirstMileService } from "../first-mile/first-mile.service"; -/** Setting code holding the global ordering window (months) for general contracts. */ -const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; -const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; +/** Everything the gateway needs to open an intent. Amount/currency are supplied by + * the caller (billing) — this service never derives them from a domain record. */ +export interface InitiateIntentInput { + /** Opaque domain reference (booking id, …). */ + referenceId: string; + /** Human-readable order ref shown on provider pages. */ + orderRef: string; + /** Authoritative amount in minor units, computed by the caller. */ + amountMinor: number; + currency: string; + /** Stored on the intent projection for receipts/dashboards. */ + reason?: string; + /** Provider/method selector. */ + method: ProviderMethod | string; + platform?: PaymentPlatformDto; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; +} + +export interface InitiateIntentResult { + intentId: string; + response: InitiateResponseDto; + /** True when the provider settled the charge synchronously during initiate. */ + immediateSuccess: boolean; + providerTxnId?: string; + paidAt?: Date; +} const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, @@ -52,6 +73,23 @@ const STATUS_MAP: Record = { "refunded": ProviderPaymentStatus.CANCELLED, }; +const PROVIDER_TO_METHOD: Record = { + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", +}; + +/** + * Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and + * NOTHING domain-specific: it never loads a booking, computes an amount, or + * advances a domain record. On settlement it notifies billing directly + * ({@link BillingService.settleByPaymentId}); billing (and through it, the domain) + * reacts. The billing↔payment pair is a deliberate forwardRef cycle. + */ @Injectable() export class PaymentService { private readonly logger = new Logger(PaymentService.name); @@ -60,27 +98,10 @@ export class PaymentService { private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BookingBatchService)) - private readonly bookingBatchService: BookingBatchService, - private readonly dropdownSettings: DropdownSettingsService, - private readonly firstMileService: FirstMileService, + @Inject(forwardRef(() => BillingService)) private readonly billing: BillingService, ) { } - /** Configured general-contract ordering window in months (defaults to 3). */ - private async contractPeriodMonths(): Promise { - try { - const setting = await this.dropdownSettings.getByCode( - CONTRACT_PERIOD_SETTING_CODE, - ); - const months = Number(setting.children?.[0]?.value); - if (Number.isFinite(months) && months > 0) return months; - } catch { - // Setting not seeded — fall back to the default. - } - return DEFAULT_CONTRACT_PERIOD_MONTHS; - } - async getAll(filters: { search?: string; status?: string; @@ -146,7 +167,6 @@ export class PaymentService { total += row.count; } - // Sum of successfully collected amounts. const paidAgg = await this.paymentRepo .createQueryBuilder("payment") .select("COALESCE(SUM(payment.amount), 0)", "sum") @@ -164,74 +184,75 @@ export class PaymentService { }; } - async initiatePayment(dto: InitiatePaymentDto): Promise { - const booking = await this.datasource - .getRepository(Booking) - .findOneBy({ id: dto.bookingId }); - if (!booking) throw new NotFoundException("Booking not found"); - - // Charge the invoice (the billing document of record) so discounts, - // penalties and staff adjustments carried on it are honored. Fall back to - // the booking total only when no invoice has been generated yet. - const invoice = await this.billing.findPayable( - Freight.InvoiceSource.Booking, - booking.id, - ); - const amountMinor = Math.round( - Number(invoice?.totalAmount ?? booking.totalAmount), - ); - + /** + * Open a gateway intent for a caller-supplied amount/reference and project it + * locally. Returns the intent id (so billing can correlate the invoice) plus + * the client action. When the provider settles synchronously, the intent is + * marked paid WITHOUT emitting — the caller (billing) settles inline after it + * has stored the intent id, avoiding a settle-before-correlation race. + */ + async initiate(input: InitiateIntentInput): Promise { const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, - referenceId: booking.id, - orderRef: booking.reference, - amountMinor, - currency: booking.paymentCurrency, - provider: dto.method as unknown as ProviderMethod, - platform: dto.platform, - payerAccount: dto.payerAccount, - returnUrl:'https://edrfreight.triaplc.com/payment/success', - failureUrl: 'https://edrfreight.triaplc.com/payment/failure', + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); - const intent = await this.syncIntentProjection(booking.id, booking, snapshot); + const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: booking.id, + const intent = await this.upsertIntent(input, snapshot); + + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + paidAt, + notify: false, }); } - return this.formatIntentResponse(intent); + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, + providerTxnId: snapshot.providerTxnId, + paidAt, + }; } - private async syncIntentProjection( - bookingId: string, - booking: Booking, + /** Create or update the local intent projection from a provider snapshot. */ + private async upsertIntent( + input: InitiateIntentInput, snapshot: PaymentIntentSnapshot, ): Promise { - const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); + const existing = await this.paymentRepo.findOneBy({ + refId: input.referenceId, + type: "booking", + }); - const PROVIDER_TO_METHOD: Record = { - TELEBIRR: "telebirr", - CBE_BIRR: "cbe-birr", - EBIRR: "ebirr", - WAAFI: "waafi", - CARD: "card", - DMONEY: "dmoney", - CAC_BANK: "cac-bank", - }; const method: PaymentEntity["method"] = PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); - const clientAction = (snapshot.clientAction ?? undefined) as Record | undefined; + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; const data = { status, method, @@ -248,30 +269,36 @@ export class PaymentService { } return this.paymentRepo.create({ - refId: bookingId, + refId: input.referenceId, type: "booking", - amount: booking.totalAmount, - currency: booking.paymentCurrency, - reason: `Payment for booking ${booking.reference}`, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, rawInitiation: snapshot as unknown as Record, clientAction: clientAction ?? {}, ...data, } as any); } - async getIntentByBookingId(bookingId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); + /** + * Reconcile an intent's status with the gateway by reference. Read-only on the + * domain side: it syncs the local projection and, when the provider reports a + * newly-observed success, notifies billing to settle. `referenceId` is opaque + * (the booking id, but this service does not load it). + */ + async getIntentByBookingId(referenceId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: referenceId, type: "booking" }); let snapshot: PaymentIntentSnapshot | null = null; try { snapshot = await this.paymentClient.getIntentByReference( PaymentReferenceType.SHIPMENT, - bookingId, + referenceId, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( - `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, + `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, ); } @@ -279,111 +306,54 @@ export class PaymentService { if (!local) throw new NotFoundException("PaymentIntent not found"); return this.formatIntentStatus(local); } + if (!local) throw new NotFoundException("PaymentIntent not found"); - const booking = await this.datasource - .getRepository(Booking) - .findOneBy({ id: bookingId }); + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - if (!booking) throw new NotFoundException("Booking not found"); - - const intent = await this.syncIntentProjection(bookingId, booking, snapshot); - - if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { - await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: booking.id, + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { providerTxnId: snapshot.providerTxnId, paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); } - const refreshed = await this.paymentRepo.findOneBy({ id: intent.id }); - return this.formatIntentStatus(refreshed ?? intent); + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); } - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async finalizePaymentSuccess(input: { - intentId: string; - bookingId: string; - providerTxnId?: string; - paidAt?: Date; - }): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === "success") return { alreadyFinalized: true }; - const paidAt = input.paidAt ?? new Date(); + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, + ); - // A general contract is paid once, up front; it does NOT enter the train - // queue (nothing has been ordered yet). Instead it becomes ACTIVE and - // opens its ordering window. Orders placed later spawn their own paid - // child bookings that go through the normal pipeline. - const booking = await this.datasource - .getRepository(Booking) - .findOne({ where: { id: input.bookingId } }); - const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT"; - - let contractExpiresAt: Date | null = null; - if (isGeneralContract) { - const months = await this.contractPeriodMonths(); - contractExpiresAt = new Date(paidAt); - contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); - } - - await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - ); - await mg.update( - Booking, - { id: input.bookingId }, - isGeneralContract - ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } - : { paymentStatus: "PAID", status: "PAID" }, - ); - - // Settle the booking's open invoice in the same transaction and link - // this payment. The invoice emits `booking.invoice.paid` for the source - // to react to. No-op if the booking has no open invoice. - await this.billing.settlePayable( - Freight.InvoiceSource.Booking, - input.bookingId, - intent.id, - mg, - ); - - await this.firstMileService.acceptBooking(input.bookingId); - - }); - - if (isGeneralContract) { - this.logger.log( - `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, - ); - return { alreadyFinalized: false }; - } - - try { - await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); - } catch (err) { - this.logger.error( - `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, - ); + if (opts.notify !== false) { + await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); } return { alreadyFinalized: false }; @@ -402,6 +372,29 @@ export class PaymentService { { id: intent.id }, { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + // NOTE: refunding still mutates the booking directly — left intact pending + // the refund redesign. TODO: route refunds through billing.refundPayable + + // a `${source}.invoice.refunded` reaction, like settlement. + await this.datasource.transaction(async (mg) => { + await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); + await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); + }); + + return { refunded: true, bookingId: dto.bookingId }; } async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { @@ -468,13 +461,12 @@ export class PaymentService { if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId: intent.id, - bookingId: event.referenceId, + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, }); return { processed: true, alreadyFinalized }; } @@ -482,7 +474,7 @@ export class PaymentService { if (event.eventType === "payment.failed") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } await this.markPaymentFailed({ intentId: intent.id, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 374eee28a..6375a0369 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -140,11 +140,18 @@ export enum InvoiceStatus { /** Originating subsystem an invoice bills for; namespaces invoice events. */ export enum InvoiceSource { Booking = "booking", - Contract = "contract", Warehouse = "warehouse", Demurrage = "demurrage", } +/** + * What an invoice bills for within its source — the discriminator when one + * entity carries several invoices (e.g. a booking's up-front vs final charge). + */ +export enum InvoiceType { + Prepaid = "PREPAID", +} + export enum SchedulingStatus { NotScheduled = "NOT_SCHEDULED", Holding = "HOLDING", From 4a7503a6672522e17a863f39d0959b86021f8055 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 09:27:43 +0000 Subject: [PATCH 21/48] fix --- .../src/pages/operations/FirstMilePage.tsx | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 358bf35c1..69b43c51d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -332,6 +332,8 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); const [distanceValue, setDistanceValue] = useState(""); + const [invoiceOpen, setInvoiceOpen] = useState(false); + const [invoiceRecord, setInvoiceRecord] = useState(null); const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), @@ -495,6 +497,16 @@ const FirstMilePage = () => { setDistanceValue(""); }; + const openInvoice = (record: FirstMileRecord) => { + setInvoiceRecord(record); + setInvoiceOpen(true); + }; + + const closeInvoice = () => { + setInvoiceOpen(false); + setInvoiceRecord(null); + }; + const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -727,7 +739,22 @@ const FirstMilePage = () => { id: "invoice", header: "Invoice", meta: { headerClassName, cellClassName }, - cell: () => "#345", + cell: ({ row }) => { + const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + if (!hasDistance) { + return ; + } + return ( + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + ); + }, }, { id: "status", @@ -1151,6 +1178,52 @@ const FirstMilePage = () => { + + {/* Invoice modal */} + Invoice #345
} + size="lg" + radius="lg" + centered + > + + {invoiceRecord && ( + <> + + + + EDR Freight + Invoice #345 + + + + + + + + + + + + + + + + Total Amount + {formatPrice(invoiceRecord.advancedPayment + invoiceRecord.remainingPayment)} + + + + + + )} + + + + + ); }; From 31b38311ffb175a10059f120d65fe556dd9c2f29 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 09:30:44 +0000 Subject: [PATCH 22/48] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 69b43c51d..dedaf8a4d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -1212,7 +1212,7 @@ const FirstMilePage = () => { Total Amount - {formatPrice(invoiceRecord.advancedPayment + invoiceRecord.remainingPayment)} + {formatPrice(parseFloat(String(invoiceRecord.advancedPayment)) + parseFloat(String(invoiceRecord.remainingPayment)))} From 9f0d16886d7057d834e966d8a5b6e79003de3678 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 09:33:56 +0000 Subject: [PATCH 23/48] fix --- .../src/pages/operations/FirstMilePage.tsx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index dedaf8a4d..9514d8c5c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -1210,9 +1210,44 @@ const FirstMilePage = () => { - - Total Amount - {formatPrice(parseFloat(String(invoiceRecord.advancedPayment)) + parseFloat(String(invoiceRecord.remainingPayment)))} + + + Post Payment + {formatPrice(invoiceRecord.remainingPayment)} + + + Advanced Payment + {formatPrice(invoiceRecord.advancedPayment)} + + + {(() => { + const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); + const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); + const difference = postPayment - advancedPayment; + + if (difference > 0) { + return ( + + Remaining to Pay + {formatPrice(difference)} + + ); + } else if (difference < 0) { + return ( + + Refund + {formatPrice(Math.abs(difference))} + + ); + } else { + return ( + + Status + Settled + + ); + } + })()} From f8810253ff4acdd5f40b04dc7cc848a96462f77b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 12:42:37 +0300 Subject: [PATCH 24/48] Migration fix --- .../migration.sql | 2 +- .../modules/passengers/passengers.service.ts | 22 ++++++--- .../src/modules/tickets/tickets.service.ts | 48 +++++++++++++++---- .../backoffice/src/app/bookings/page.tsx | 2 +- .../backoffice/src/app/tickets/page.tsx | 46 ++++++++++++------ 5 files changed, 89 insertions(+), 31 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..aa6cd855a 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 @@ -107,7 +107,7 @@ CREATE TABLE "system_features" ( "is_enabled" BOOLEAN NOT NULL DEFAULT false, "config" JSONB, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "system_features_pkey" PRIMARY KEY ("id") ); diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 8805bc3c6..b2b7ae631 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -115,25 +115,35 @@ export class PassengersService { const guestBooking = (passenger as any)?.bookings?.[0] ?? null; const guestSeat = guestBooking?.seats?.[0] ?? null; + // Parse notes JSON to extract phone and other data + let notesData: any = null; + if (profile.notes) { + try { + notesData = typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes; + } catch { + notesData = null; + } + } + return { id: profile.id, fullName: profile.fullName, - email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null, - phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null, + email: localUser?.email ?? iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null, + phone: localUser?.phone ?? iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null, gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null, dateOfBirth: profile.dateOfBirth ? new Date(profile.dateOfBirth).toISOString().split('T')[0] : (localUser?.dateOfBirth ? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth) : iam?.metadata?.dateOfBirth ?? null), - nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null), + nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null), nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null, faydaVerified, faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null, - passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null, - passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null, + passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null, + passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null, passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null, - idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null, + idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null), verified: faydaVerified, lastLoginAt: localUser?.lastLoginAt ?? null, role: localUser?.role ?? null, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 8e78d710b..3c07d8c40 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -54,11 +54,11 @@ export class TicketsService { ...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}), }; } - // if (filters.coachId) { - // where.seat = { - // coachId: filters.coachId - // }; - // } + if (filters.coachId) { + where.seat = { + coachId: filters.coachId + }; + } const [tickets, total] = await Promise.all([ this.prisma.ticket.findMany({ @@ -67,8 +67,8 @@ export class TicketsService { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } }, - passenger: { select: { id: true, iamUserId: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + passenger: { include: { travelerProfiles: true } }, seats: { include: { seat: { include: { coach: true } } } }, }, }, @@ -93,9 +93,41 @@ export class TicketsService { return { items: tickets.map((t: any) => { const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined; + + // Extract phone from TravelerProfile notes JSON + let guestPhone = null; + let guestEmail = null; + const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName); + + // DEBUG: Log to see what we're getting + this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`); + if (matchingProfile) { + this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`); + } + + if (matchingProfile?.notes) { + try { + const notesData = JSON.parse(matchingProfile.notes); + guestPhone = notesData.phone || null; + guestEmail = notesData.email || null; + this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`); + } catch (err) { + this.logger.error(`Failed to parse notes JSON: ${err}`); + } + } + + // Fallback to booking contact info if no match in TravelerProfile + if (!guestPhone) guestPhone = t.booking?.contactPhone; + if (!guestEmail) guestEmail = t.booking?.contactEmail; + + this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`); + const passengerInfo = iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } - : { fullName: 'Guest', email: t.booking?.contactEmail, phone: null }; + : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; + + this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`); + return { id: t.id, ticketNumber: t.barcodePayload, diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index b4cf005e8..d841d144d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -185,7 +185,7 @@ function BookingsPageContent() { }, }, { - key: 'contact', label: 'Contact', + key: 'contact', label: 'Primary contact', render: (booking: any) => (
{booking.contactPhone || booking.passenger?.phone}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index fce68fb34..31aae74fd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -348,14 +348,8 @@ export default function TicketsPage() { key: 'contact', label: 'Contact', render: (ticket: any) => { - // Find the booking seat that matches this ticket's passenger - const matchingSeat = ticket.booking?.seats?.find((s: any) => - s.passengerName === ticket.passengerName && s.leg === ticket.leg - ); - - // Try to get phone from BookingSeat first, then fall back to booking contact - const phone = matchingSeat?.phone || ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'; - const email = matchingSeat?.email || ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'; + const phone = ticket.booking?.passenger?.phone || 'N/A'; + const email = ticket.booking?.passenger?.email || 'N/A'; return (
@@ -372,16 +366,21 @@ export default function TicketsPage() { label: 'Trip', render: (ticket: any) => { const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; - const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt; + const returnDeparture = ticket.booking?.returnSchedule?.departureAt; + return (
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
- {ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} - {isRoundTrip && ( - → {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'} + {!isRoundTrip ? ( + {ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} + ) : ( + + {ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} · + {returnDeparture ? formatDateTimeShort(returnDeparture) : 'N/A'} + )}
@@ -425,9 +424,26 @@ export default function TicketsPage() { { key: 'arrivalDate', label: 'Arrival Date', - render: (ticket: any) => ( - {ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'} - ), + render: (ticket: any) => { + const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; + const outboundArrival = ticket.schedule?.arrivalAt; + const returnArrival = ticket.booking?.returnSchedule?.arrivalAt; + + if (!isRoundTrip) { + return ( + + {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'} + + ); + } + + return ( +
+ ➡ {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'} + ⬅ {returnArrival ? new Date(returnArrival).toLocaleDateString() : '—'} +
+ ); + }, }, { key: 'boardingTimes', From fc03e27bdc13570d1e0e72acd0869993893663ca Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 09:43:31 +0000 Subject: [PATCH 25/48] fix --- .../src/pages/operations/LastMilePage.tsx | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 5535809a2..894a5a091 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -5,6 +5,7 @@ import { MoreHorizontal, Printer, RefreshCw, + Ruler, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -21,12 +22,14 @@ import { Group, Menu, Modal, + NumberInput, ScrollArea, Select, SimpleGrid, Stack, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem } from "@/types/warehouse"; import { warehouseService } from "@/services/warehouse.service"; @@ -41,6 +44,7 @@ import { lastMileService, } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; +import { ratesService } from "@/services/rates.service"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -311,6 +315,11 @@ const LastMilePage = () => { const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); const [arrivalSearch, setArrivalSearch] = useState(""); + const [distanceOpen, setDistanceOpen] = useState(false); + const [distanceValue, setDistanceValue] = useState(""); + const [invoiceOpen, setInvoiceOpen] = useState(false); + const [invoiceRecord, setInvoiceRecord] = useState(null); + const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.LAST_MILE.list(), queryFn: async () => { @@ -327,6 +336,14 @@ const LastMilePage = () => { }, }); + const { data: ratesData } = useQuery({ + queryKey: ["rates", "LAST_MILE"], + queryFn: async () => { + const res = await ratesService.getByType("LAST_MILE"); + return res.data; + }, + }); + const records = listData?.data ?? []; const vehicleOptions = useMemo( @@ -352,6 +369,21 @@ const LastMilePage = () => { }, }); + const updateDistanceMutation = useMutation({ + mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => + lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); + if (activeRecord) { + toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); + } + closeDistance(); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), @@ -422,6 +454,49 @@ const LastMilePage = () => { acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue }); }; + const openDistance = (id: string) => { + setActiveId(id); + setDistanceValue(""); + setDistanceOpen(true); + }; + + const closeDistance = () => { + setDistanceOpen(false); + setActiveId(null); + setDistanceValue(""); + }; + + const openInvoice = (record: LastMileRecord) => { + setInvoiceRecord(record); + setInvoiceOpen(true); + }; + + const closeInvoice = () => { + setInvoiceOpen(false); + setInvoiceRecord(null); + }; + + const handleSaveDistance = () => { + const distance = parseFloat(distanceValue); + if (!activeId || isNaN(distance) || distance < 0) { + toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + return; + } + + let remainingPayment: number | undefined; + if (ratesData?.data) { + const lastMileRate = ratesData.data.find( + (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") + ); + if (lastMileRate) { + const rateValue = parseFloat(lastMileRate.rateValue); + remainingPayment = distance * rateValue; + } + } + + updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + }; + const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -639,6 +714,27 @@ const LastMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , }, + { + id: "invoice", + header: "Invoice", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; + if (!hasDistance) { + return ; + } + return ( + openInvoice(row.original)} + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + #345 + + ); + }, + }, { id: "status", header: "Status", @@ -704,6 +800,12 @@ const LastMilePage = () => { > View detail + } + onClick={() => openDistance(row.original.id)} + > + Add distance + {canPrint && ( } @@ -1000,6 +1102,134 @@ const LastMilePage = () => { + + {/* Add Actual Distance modal */} + Add Actual Distance} + size="md" + radius="lg" + centered + > + + {activeRecord && ( + + + {bookingRef(activeRecord)} + + Customer + {customerName(activeRecord)} + + + Est. Distance (KM) + {activeRecord.estimatedKm ?? "—"} + + + + )} + setDistanceValue(String(v ?? ""))} + min={0} + step={0.1} + decimalScale={2} + /> + + + + + + + + {/* Invoice modal */} + Invoice #345} + size="lg" + radius="lg" + centered + > + + {invoiceRecord && ( + <> + + + + EDR Freight + Invoice #345 + + + + + + + + + + + + + + + + + Post Payment + {formatPrice(invoiceRecord.remainingPayment)} + + + Advanced Payment + {formatPrice(invoiceRecord.advancedPayment)} + + + {(() => { + const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); + const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); + const difference = postPayment - advancedPayment; + + if (difference > 0) { + return ( + + Remaining to Pay + {formatPrice(difference)} + + ); + } else if (difference < 0) { + return ( + + Refund + {formatPrice(Math.abs(difference))} + + ); + } else { + return ( + + Status + Settled + + ); + } + })()} + + + + + + )} + + + + + ); }; From 2551f76f8a9f7b6a339b4e5f99f6f5dfeaef792b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 09:50:29 +0000 Subject: [PATCH 26/48] fix: reference type in payment and billing --- .../1821000000004-MakePaymentsTypeGeneric.ts | 47 +++++++++++++++++++ .../src/modules/billing/billing.service.ts | 8 +++- .../payment/entities/payment.entity.ts | 8 +++- .../src/modules/payment/payment.service.ts | 20 ++++---- 4 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts new file mode 100644 index 000000000..9f0e8e7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make the payment projection source-agnostic so any domain (not just bookings) + * can own a payment intent. + * + * - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the + * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a + * new domain no longer needs an enum migration to write its intents. + * - adds `payments.reference_type varchar(40)` — the gateway reference type + * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll + * path can query the provider without hardcoding it. + * + * Matches payment/entities/payment.entity.ts. + */ +export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface { + name = "MakePaymentsTypeGeneric1821000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`, + ); + await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`); + + await queryRunner.query( + `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`, + ); + + // Restore the single-value enum. Any non-'booking' rows would block the cast; + // collapse them first so the down migration is safe. + await queryRunner.query( + `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`, + ); + await queryRunner.query( + `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`, + ); + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 448ad6685..1d0473c7e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,6 +1,6 @@ import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { Freight } from "@edr/types"; +import { Freight, PaymentReferenceType } from "@edr/types"; import { DataSource, EntityManager, In } from "typeorm"; import { Invoice } from "./entities/invoice.entity"; @@ -417,6 +417,12 @@ export class BillingService { const result = await this.payment.initiate({ referenceId: sourceId, + source: invoice.source, + // Gateway reference type derives from the invoice source by convention + // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and + // the domain never supplies it. New sources add their uppercased value to + // the PaymentReferenceType enum. + referenceType: invoice.source.toUpperCase() as PaymentReferenceType, orderRef: invoice.invoiceNumber, amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 2bb81a331..5c4a4f7f7 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat import { PaymentRefundEntity } from "./payment-refund.entity"; -type PaymentType = "booking" +/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ +type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity { @Column({ type: 'varchar', length: 255, name: "ref_id" }) refId!: string - @Column({ type: "enum", enum: ["booking"] }) + @Column({ type: "varchar", length: 50 }) type!: PaymentType; + @Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" }) + referenceType?: string; + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) method!: PaymentMethod diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 7beb6e8c6..a712e4ad8 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -40,6 +40,10 @@ import { export interface InitiateIntentInput { /** Opaque domain reference (booking id, …). */ referenceId: string; + /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ + source: string; + /** Gateway reference type the intent is opened with (caller's domain decides it). */ + referenceType: PaymentReferenceType; /** Human-readable order ref shown on provider pages. */ orderRef: string; /** Authoritative amount in minor units, computed by the caller. */ @@ -194,7 +198,7 @@ export class PaymentService { async initiate(input: InitiateIntentInput): Promise { const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, + referenceType: input.referenceType, referenceId: input.referenceId, orderRef: input.orderRef, amountMinor: input.amountMinor, @@ -240,7 +244,6 @@ export class PaymentService { ): Promise { const existing = await this.paymentRepo.findOneBy({ refId: input.referenceId, - type: "booking", }); const method: PaymentEntity["method"] = @@ -270,7 +273,8 @@ export class PaymentService { return this.paymentRepo.create({ refId: input.referenceId, - type: "booking", + type: input.source, + referenceType: input.referenceType, amount: input.amountMinor, currency: input.currency as PaymentEntity["currency"], reason: input.reason ?? `Payment for ${input.orderRef}`, @@ -287,12 +291,12 @@ export class PaymentService { * (the booking id, but this service does not load it). */ async getIntentByBookingId(referenceId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: referenceId, type: "booking" }); + const local = await this.paymentRepo.findOneBy({ refId: referenceId }); let snapshot: PaymentIntentSnapshot | null = null; try { snapshot = await this.paymentClient.getIntentByReference( - PaymentReferenceType.SHIPMENT, + (local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT, referenceId, ); } catch (err) { @@ -423,7 +427,7 @@ export class PaymentService { } findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id, type: "booking" }); + return this.paymentRepo.findOneBy({ refId: id }); } formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { @@ -459,7 +463,7 @@ export class PaymentService { failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } @@ -472,7 +476,7 @@ export class PaymentService { } if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } From b3ec6e4235b4154b606fc248750e9f2ef0d5ff36 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 29 Jun 2026 13:51:05 +0300 Subject: [PATCH 27/48] refactor: ( iam ) user real uuid --- .../src/seed/edr-passenger.seed.ts | 2 +- .../seed/passenger-permissions.registry.ts | 40 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts index 8413cadb2..cd178139a 100644 --- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -11,7 +11,7 @@ export type PassengerSeedRole = { }; export const EDR_PASSENGER_APPLICATION = { - id: 'd2000001-0001-4000-8000-000000000001', + id: '921cd1a4-98a7-4601-bfb1-6fe19518be52', key: 'edr_passenger_app', name: { am: 'EDR Passenger App', diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index 9b06c812b..d6a804b3a 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -15,26 +15,26 @@ const perm = (id: string, key: string, en: string): PassengerPermissionSeed => ( }); export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ - perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'), - perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'), - perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), - perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'), - perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'), - perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'), - perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'), - perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'), - perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'), - perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), - perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'), - perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'), - perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), - perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'), - perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'), - perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'), - perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'), - perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'), - perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'), - perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'), + perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'), + perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'), + perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'), + perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'), + perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'), + perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'), + perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'), + perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), + perm('bf6cbd48-7a4a-46ec-91f3-0a23245da0a4', 'edr_passenger_app:reports:view', 'View reports'), + perm('53b49280-a272-4688-8345-e14fbedce50e', 'edr_passenger_app:fraud:view', 'View fraud alerts'), + perm('834f576c-afe2-41f4-9e6e-5f87b155fbf4', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), + perm('988b680d-df5a-4e79-9c01-c9441753ce1a', 'edr_passenger_app:audit:view', 'View audit logs'), + perm('38736897-545f-47dc-9531-7fc381478a1f', 'edr_passenger_app:agents:view', 'View agents'), + perm('30ada94a-cd15-4588-97bd-cd2f9d33ad7d', 'edr_passenger_app:agents:manage', 'Manage agents'), + perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'), + perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'), + perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'), + perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'), ]; export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key); From 835facc65a0f039c7cade84e7ce5aa37bf72be01 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 11:12:51 +0000 Subject: [PATCH 28/48] fix --- .../first-mile/entities/first-mile.entity.ts | 3 +++ .../last-mile/entities/last-mile.entity.ts | 3 +++ .../src/pages/operations/FirstMilePage.tsx | 15 ++++++++++++++- .../src/pages/operations/LastMilePage.tsx | 15 ++++++++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 17ed1c101..513aaf98e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 3bfe2cd19..6c8c9d1ca 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -34,6 +34,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9514d8c5c..e17ea46c7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -314,6 +314,7 @@ const FirstMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -529,6 +530,7 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -566,7 +568,7 @@ const FirstMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -887,6 +889,17 @@ const FirstMilePage = () => { ); })} + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 894a5a091..9798a90bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -298,6 +298,7 @@ const LastMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -508,6 +509,7 @@ const LastMilePage = () => { ); const matchesFilter = (r: LastMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -545,7 +547,7 @@ const LastMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -866,6 +868,17 @@ const LastMilePage = () => { ); })} + From 662d96571eafa798306165ecfebfde1c3c96ab4a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 11:14:42 +0000 Subject: [PATCH 29/48] fix --- .../booking-orders/booking-orders.service.ts | 470 ------------------ .../bookings/booking-invoice.service.ts | 5 +- 2 files changed, 3 insertions(+), 472 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts 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 deleted file mode 100644 index 210b65b3d..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { - BadRequestException, - forwardRef, - Inject, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { BookingPricingService } from '../bookings/booking-pricing.service'; -import { clearanceCodesForBooking } from '../bookings/clearance.util'; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingContainer } from '../bookings/entities/booking-container.entity'; -import { CompaniesService } from '../companies/companies.service'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; -import { RatesService } from '../rule-engine/services/rates.service'; -import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; -import { eatDay } from '../train-scheduling/batch-window.util'; -import { BookingOrdersRepository } from './booking-orders.repository'; -import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; -import { BookingOrder } from './entities/booking-order.entity'; -import { BookingOrderLine } from './entities/booking-order-line.entity'; -import { GeneralContractService } from './general-contract.service'; -import { isRoadService, roadKmPrice } from './road.util'; - -@Injectable() -export class BookingOrdersService { - private readonly logger = new Logger(BookingOrdersService.name); - - constructor( - private readonly dataSource: DataSource, - private readonly ordersRepository: BookingOrdersRepository, - private readonly bookingsRepository: BookingsRepository, - private readonly companiesService: CompaniesService, - private readonly generalContractService: GeneralContractService, - private readonly pricingService: BookingPricingService, - private readonly ratesService: RatesService, - @Inject(forwardRef(() => TrainSchedulingService)) - private readonly trainSchedulingService: TrainSchedulingService, - ) {} - - /** Orders placed against a contract, with their lines and child booking. */ - async listByContract(contractBookingId: string): Promise { - const orders = await this.ordersRepository.findByContract(contractBookingId); - await Promise.all(orders.map((o) => this.syncOrderFromChild(o))); - return orders; - } - - async findById(id: string): Promise { - const order = await this.ordersRepository.findById(id); - if (order) await this.syncOrderFromChild(order); - return order; - } - - /** - * The order is a ledger row; the spawned child ONE_TIME booking is what - * actually moves through the workflow (clearance → marketing/ops accept → - * pay → allocate), exactly like a one-time booking. Nothing writes the order - * row after creation, so its stored status would stay 'PENDING' forever. - * - * Mirror the child onto the order whenever it is read: copy the child's - * status, schedulingStatus and trainScheduleId onto the order (mutating the - * in-memory instance the caller gets back), and persist that snapshot when it - * has drifted so list/detail views and any stored reporting stay in sync. - */ - private async syncOrderFromChild(order: BookingOrder): Promise { - const child = order.booking; - if (!child) return; - - const nextStatus = child.status; - const nextScheduling = child.schedulingStatus; - const nextTrainScheduleId = child.trainScheduleId ?? null; - - const drifted = - order.status !== nextStatus || - order.schedulingStatus !== nextScheduling || - (order.trainScheduleId ?? null) !== nextTrainScheduleId; - - // Reflect the child onto the instance returned to the caller. - order.status = nextStatus; - order.schedulingStatus = nextScheduling; - order.trainScheduleId = nextTrainScheduleId; - - if (drifted) { - await this.ordersRepository.update(order.id, { - status: nextStatus, - schedulingStatus: nextScheduling, - trainScheduleId: nextTrainScheduleId, - }); - } - } - - /** - * Place a drawdown order against an ACTIVE general contract. - * - * Validates the requested quantities against the remaining pool, then spawns a - * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's - * route/cargo/service) so it flows through the existing train-scheduling - * pipeline. The order row is the ledger entry linking contract → child booking. - */ - async create( - dto: CreateBookingOrderDto, - userId?: string, - ): Promise { - const contract = await this.bookingsRepository.findById(dto.contractBookingId); - if (!contract) { - throw new NotFoundException(`Contract ${dto.contractBookingId} not found`); - } - if (!this.generalContractService.isGeneralContract(contract)) { - throw new BadRequestException('Booking is not a general contract'); - } - if (contract.status !== 'CONTRACT_ACTIVE') { - throw new BadRequestException( - `Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`, - ); - } - if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) { - throw new BadRequestException('Contract ordering window has expired'); - } - - // The customer placing the order must own the contract. - if (userId && !(await this.userOwnsContract(userId, contract))) { - throw new BadRequestException('You do not have access to this contract'); - } - - // Resolve the route the order ships on: a chosen contract route line for a - // multi-route contract, else the contract's own origin/destination. - const routeLines = await this.generalContractService.getRouteLines( - contract.id, - ); - let originYardId = contract.originYardId; - let destinationYardId = contract.destinationYardId; - let routeLineId: string | null = null; - let routeKm: number | null = null; - - if (routeLines.length > 0) { - if (!dto.routeLineId) { - throw new BadRequestException( - 'This contract has multiple routes — select a route to draw from', - ); - } - const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId); - if (!chosen) { - throw new BadRequestException( - 'Selected route is not part of this contract', - ); - } - originYardId = chosen.originYardId; - destinationYardId = chosen.destinationYardId; - routeLineId = chosen.routeLineId; - routeKm = chosen.km ?? null; - } - - // Validate the route has a departure on the chosen day. - const day = eatDay(new Date(dto.scheduledDate)); - const hasDeparture = - await this.trainSchedulingService.existsOpenScheduleOnRouteDay( - originYardId, - destinationYardId, - day, - ); - if (!hasDeparture) { - throw new BadRequestException( - 'No departures available on the selected day for this route', - ); - } - - const isContainer = contract.freightType === 'CONTAINER'; - - // Hazardous/reefer counts the customer entered cannot exceed the line they - // belong to. Validated for every order regardless of routing. - for (const line of dto.lines) { - const haz = line.hazardousQuantity ?? 0; - const reefer = line.reeferQuantity ?? 0; - if (haz < 0 || reefer < 0) { - throw new BadRequestException('Hazardous/reefer quantities cannot be negative'); - } - if (haz > line.quantity || reefer > line.quantity) { - throw new BadRequestException( - 'Hazardous/reefer quantity cannot exceed the line quantity', - ); - } - } - - // The contract has a single shared drawdown pool (per container type for - // CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route - // only fixed origin/destination/km above — so every order, routed or not, - // validates each line against the same shared pool. - const poolLines = await this.generalContractService.getQuantityLines( - contract.id, - ); - for (const line of dto.lines) { - if (line.quantity <= 0) { - throw new BadRequestException('Order quantities must be greater than zero'); - } - const key = isContainer ? (line.containerTypeId ?? '') : ''; - const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); - if (!poolLine) { - throw new BadRequestException( - isContainer - ? `Container type ${line.containerTypeId} is not part of this contract` - : 'This contract has no matching quantity pool', - ); - } - if (line.quantity > poolLine.remainingQuantity) { - throw new BadRequestException( - `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + - (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), - ); - } - } - - // Persist the order + its child shipment booking atomically. - const order = await this.dataSource.transaction(async (manager) => { - const childBooking = await this.spawnChildBooking( - contract, - dto, - { originYardId, destinationYardId, km: routeKm }, - manager, - ); - - const reference = await this.generateReference(); - const orderRow = manager.create(BookingOrder, { - reference, - contractBookingId: contract.id, - bookingId: childBooking.id, - routeLineId, - companyId: contract.companyId ?? null, - scheduledDate: new Date(dto.scheduledDate), - // The order is a ledger row; the child booking drives the workflow - // (review → pay → allocate), so the order tracks PENDING until done. - status: 'PENDING', - schedulingStatus: 'NOT_SCHEDULED', - }); - const savedOrder = await manager.save(orderRow); - - const lines = dto.lines.map((l) => - manager.create(BookingOrderLine, { - orderId: savedOrder.id, - containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, - quantity: l.quantity, - hazardousQuantity: l.hazardousQuantity ?? 0, - reeferQuantity: l.reeferQuantity ?? 0, - }), - ); - await manager.save(lines); - savedOrder.lines = lines; - return savedOrder; - }); - - // The child does NOT enter the train batch pool here. It is priced and - // unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs - // clearance first; the batch enqueue happens only on accept. - - // Close the contract once its pool is exhausted (pending orders count, so - // the pool reserves quantity as soon as an order is placed). - if (await this.generalContractService.isExhausted(contract.id)) { - await this.dataSource - .getRepository(Booking) - .update(contract.id, { status: 'CONTRACT_CLOSED' }); - this.logger.log( - `Contract ${contract.reference} CLOSED — quantity exhausted`, - ); - } - - return (await this.ordersRepository.findById(order.id)) ?? order; - } - - /** - * Create the ONE_TIME child booking for an order, inheriting the contract's - * shipment context. Unlike the contract (which is no longer paid up front), - * the child is PRICED and UNPAID and waits for Marketing review — going - * through the customs clearance gate first when the service includes customs, - * mirroring a one-time booking. It only enters the train pool on accept. - */ - private async spawnChildBooking( - contract: Booking, - dto: CreateBookingOrderDto, - route: { originYardId: string; destinationYardId: string; km: number | null }, - manager: import('typeorm').EntityManager, - ): Promise { - const reference = await this.generateChildBookingReference(); - const isContainer = contract.freightType === 'CONTAINER'; - - // Sum line quantities × the contract's per-unit weight for the child total. - const containerByType = new Map( - (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]), - ); - let totalWeight = 0; - if (isContainer) { - for (const line of dto.lines) { - const src = containerByType.get(line.containerTypeId ?? ''); - const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; - totalWeight += vgmPerUnit * line.quantity; - } - } else { - totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); - } - - // Per-order hazardous/reefer: set the child flags from the order's line - // counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply. - const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0); - const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0); - - // Customs orders flow through the one-time clearance gate first; others go - // straight to operations review with the chosen shipment day. - const { includesCustoms } = clearanceCodesForBooking(contract); - const spawnStatus = includesCustoms - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING'; - - const child = manager.create(Booking, { - reference, - // 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, - previousContractId: contract.id, - serviceTypeId: contract.serviceTypeId, - firstMilePickupAddress: contract.firstMilePickupAddress ?? null, - lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, - equipmentReturn: contract.equipmentReturn, - originYardId: route.originYardId, - destinationYardId: route.destinationYardId, - tradeDirection: contract.tradeDirection, - freightType: contract.freightType, - cargoTypeId: contract.cargoTypeId ?? null, - cargoFreeText: contract.cargoFreeText ?? null, - shippingLineId: contract.shippingLineId ?? null, - cargoTotalWeightVgm: totalWeight, - isHazardous: hasHazardous, - isReefer: hasReefer, - paymentCurrency: contract.paymentCurrency, - bookingType: 'ONE_TIME', - scheduledDate: new Date(dto.scheduledDate), - // Priced + unpaid: the customer pays this order on its own. - status: spawnStatus, - paymentStatus: 'PENDING', - priorityScore: contract.priorityScore, - totalAmount: 0, - schedulingStatus: 'NOT_SCHEDULED', - }); - const savedChild = await manager.save(child); - - if (isContainer) { - for (const line of dto.lines) { - const src = containerByType.get(line.containerTypeId ?? ''); - const ct = line.containerTypeId - ? await manager.getRepository(ContainerType).findOne({ - where: { id: line.containerTypeId }, - }) - : null; - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; - const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; - const row = manager.create(BookingContainer, { - bookingId: savedChild.id, - containerTypeId: line.containerTypeId ?? null, - quantity: line.quantity, - vgmPerUnitTons: vgmPerUnit, - totalVgmTons: vgmPerUnit * line.quantity, - wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit), - isOverweight: false, - }); - await manager.save(row); - } - } - - // Price the order: base freight for the drawn quantity + haz/reefer - // surcharges, plus a road KM charge when the service ships by road. - const roadKm = isRoadService(contract.serviceType) ? route.km : null; - await this.priceChildBooking(savedChild.id, roadKm, manager); - - return savedChild; - } - - /** - * Compute and persist the child order's price (base + surcharges) inside the - * order transaction. The contract is no longer paid up front, so each order - * carries its own total that the customer pays. - */ - private async priceChildBooking( - childId: string, - roadKm: number | null, - manager: import('typeorm').EntityManager, - ): Promise { - const child = await manager.getRepository(Booking).findOne({ - where: { id: childId }, - relations: { bookingContainers: true }, - }); - if (!child) return; - - try { - const computed = await this.pricingService.computePriceForBooking(child); - const lineItems = [...computed.lineItems]; - let total = computed.totalAmount; - - // Road KM charge: distance × the live PER_KM rate, added as its own line. - if (roadKm && roadKm > 0) { - const perKmRate = await this.findPerKmRate(child.paymentCurrency); - const kmAmount = roadKmPrice(roadKm, perKmRate); - if (kmAmount > 0) { - lineItems.push({ - code: 'ROAD_KM', - description: `Road transport (${roadKm} km)`, - amount: kmAmount, - unitAmount: perKmRate!, - unit: 'PER_KM', - quantity: roadKm, - currency: child.paymentCurrency, - }); - total += kmAmount; - } - } - - await manager.getRepository(Booking).update(childId, { - totalAmount: total, - priorityScore: computed.priorityScore, - pricingBreakdown: { - lineItems, - totalAmount: total, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - } catch (err) { - this.logger.error( - `Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - /** The live PER_KM rate value for road billing, in the given currency. */ - private async findPerKmRate(currency: string): Promise { - const rates = await this.ratesService.findLiveRates(); - const rate = rates.find( - (r) => r.rateUnit === 'PER_KM' && r.currency === currency, - ); - return rate ? Number(rate.rateValue) : null; - } - - private async userOwnsContract( - userId: string, - contract: Booking, - ): Promise { - if (!contract.companyId) return true; // government / staff-created - try { - const { company } = await this.companiesService.getCompanyInfoByUserId( - userId, - ); - return company.id === contract.companyId; - } catch { - return false; - } - } - - private async generateReference(): Promise { - const year = new Date().getFullYear(); - const count = await this.ordersRepository.countByYear(year); - return `ORD-${year}-${String(count + 1).padStart(6, '0')}`; - } - - private async generateChildBookingReference(): Promise { - const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - return `BK-${year}-${String(count + 1).padStart(6, '0')}`; - } -} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 4eacedc13..6cd2f4be5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -52,7 +52,7 @@ export class BookingInvoiceService { private readonly firstMile: FirstMileService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, - ) {} + ) { } /** * Ensure the booking has its invoice, generating one from the snapshotted @@ -122,7 +122,8 @@ export class BookingInvoiceService { if (booking.paymentStatus === 'PAID') return; const paidAt = new Date(); - const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; + const isGeneralContract = false + // booking.bookingType === 'GENERAL_CONTRACT'; let contractExpiresAt: Date | null = null; if (isGeneralContract) { From 5c36c09daeef81919df457cf0deca2ac1daeaec6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:01:19 +0000 Subject: [PATCH 30/48] fix --- .../modules/first-mile/first-mile.service.ts | 21 +++++++++++------- .../modules/last-mile/last-mile.service.ts | 22 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a06f9eb87..48aa23084 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -4,6 +4,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; @@ -37,6 +38,7 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} /** @@ -251,16 +253,19 @@ export class FirstMileService { const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.firstMilePickupAddress, - destinationYard: booking?.originYard?.label, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + + (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 2e8fb2463..998d28c68 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -4,6 +4,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; @@ -32,12 +33,12 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( - private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} async acceptBooking(bookingReference: string): Promise { @@ -185,16 +186,19 @@ export class LastMileService { }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.destinationYard?.label, - destinationYard: booking?.lastMileDeliveryAddress, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a last-mile delivery. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') + + (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } From 079351f7c467affb4d3f45ef68309a67742d34ce Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 29 Jun 2026 15:05:46 +0300 Subject: [PATCH 31/48] fix: migration problem --- .../20260101000000_add_configurable_fare_system/migration.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 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 aa6cd855a..351c44637 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 @@ -113,5 +113,5 @@ CREATE TABLE "system_features" ( ); -- 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 INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") +VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP); \ No newline at end of file From b22ab739dbab12bfbb1eb10528220c065a437519 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:08:16 +0000 Subject: [PATCH 32/48] fix --- .../edr-freight-api/src/modules/first-mile/first-mile.service.ts | 1 - apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 48aa23084..caa6e2b1d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -37,7 +37,6 @@ export class FirstMileService { private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, private readonly smsClient: SmsClientService, ) {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 998d28c68..b1c984326 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -37,7 +37,6 @@ export class LastMileService { private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, private readonly smsClient: SmsClientService, ) {} From a64e53132446cc6b5f5cb90c4fc4ec787c3363b8 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:15:30 +0000 Subject: [PATCH 33/48] fix --- .../edr-freight-api/src/modules/first-mile/first-mile.service.ts | 1 - apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index caa6e2b1d..45c2658db 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -3,7 +3,6 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b1c984326..77a8a2fea 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -3,7 +3,6 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; From fc95d48d28ef977b728cdc4aae328514052735a2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:25:36 +0000 Subject: [PATCH 34/48] fix --- apps/edr-freight-api/src/modules/payment/payment.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1d56cb1cf..e9f74f741 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -293,8 +293,6 @@ export class PaymentService { // if (!intent) throw new NotFoundException("PaymentIntent not found"); // if (intent.status === "success") return { alreadyFinalized: true }; - const paidAt = input.paidAt ?? new Date(); - // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { From 98959c03ccf17e77669a65f0c7522c5034f5e2b7 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 16:03:00 +0300 Subject: [PATCH 35/48] 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 36/48] 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 37/48] 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 38/48] 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 39/48] 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 40/48] 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 41/48] 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 42/48] 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 43/48] 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 44/48] 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 */} -
- -