From aa59e30ea789c243db21eb77e309e0ab96c749a8 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 13:38:48 +0000 Subject: [PATCH 01/63] 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/63] 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/63] 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/63] 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/63] 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/63] chore: add company select to backoffice booking --- .../src/pages/bookings/NewBookingPage.tsx | 81 ++++++++++++++++--- .../backoffice/src/types/customer.ts | 5 ++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index e685a3cf4..690da9998 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -180,8 +180,10 @@ export default function NewBookingPage() { const queryClient = useQueryClient(); const [isGovernment, setIsGovernment] = useState(false); - const [governmentInstitution, setGovernmentInstitution] = useState(""); const [companyId, setCompanyId] = useState(null); + // Government bookings bill to a real government company + an explicit profile. + const [govCompanyId, setGovCompanyId] = useState(null); + const [govProfileId, setGovProfileId] = useState(null); const [freightType, setFreightType] = useState("CONTAINER"); const [originYardId, setOriginYardId] = useState(null); const [destinationYardId, setDestinationYardId] = useState(null); @@ -220,6 +222,42 @@ export default function NewBookingPage() { label: c.name || c.email || c.tin || c.id, })); + // Active government companies (kind=government) the booking can bill to. + const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({ + queryKey: ["companies", "government", "active"], + queryFn: () => + customersService.list({ + page: 1, + pageSize: 1000, + kind: "government", + status: "active", + }), + enabled: isGovernment, + }); + + const govCompanies = govCompaniesPage?.items ?? []; + const govCompanyOptions = govCompanies.map((c) => ({ + value: c.id, + label: c.name || c.tin || c.id, + })); + + // Profiles (importer/exporter) of the chosen government company — the booking + // must link to one explicitly. + const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId); + const govProfileOptions = (selectedGovCompany?.companyProfiles ?? []) + .filter((p) => p.status === "active") + .map((p) => ({ + value: p.id, + label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${ + p.reference ? ` — ${p.reference}` : "" + }`, + })); + + // Reset the chosen profile when the government company changes. + useEffect(() => { + setGovProfileId(null); + }, [govCompanyId]); + // Day-level pool: fetch only the days that have a departure on the route (no // train, no capacity). The batch engine assigns the train after booking. const { data: availableDays, isLoading: daysLoading } = useQuery( @@ -306,7 +344,7 @@ export default function NewBookingPage() { Boolean(tradeDirection) && Boolean(serviceTypeId) && departureSatisfied && - (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && + (isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) && (freightType === "BULK" ? Boolean(cargoTypeId) && bulkWeight > 0 : allLinesValid); @@ -320,8 +358,8 @@ export default function NewBookingPage() { mutationFn: () => bookingsService.create({ isGovernment, - governmentInstitution: isGovernment ? governmentInstitution : undefined, - companyId: isGovernment ? undefined : companyId || undefined, + companyId: isGovernment ? govCompanyId || undefined : companyId || undefined, + companyProfileId: isGovernment ? govProfileId || undefined : undefined, freightType, contractType: "NEW", equipmentReturn, @@ -390,18 +428,37 @@ export default function NewBookingPage() { setIsGovernment(e.currentTarget.checked)} /> {isGovernment ? ( - setGovernmentInstitution(e.currentTarget.value)} - required - /> + + + ) : ( + +
+ + +
+ +
+ + not checked +
+ + + +
+

1 · Choose a booking

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

2 · Initiate payment

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

+
+ + +
+

3 · Track intent & receipt

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

Last response

+
+
+
+

Request log

+
+
+
+ + + + + From f7f0f6aef3b60b5dc3ecd4ddcbf9b2de187d3304 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 13:45:35 +0000 Subject: [PATCH 15/63] 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 16/63] 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 17/63] 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 18/63] 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 19/63] 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 */} -
- -