diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 71e2fd60a..9edf388b9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", "predev": "pnpm run clean", - "dev": "nest start --watch", + "dev": "nest start --watch --clearScreen false", "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", diff --git a/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts new file mode 100644 index 000000000..55239c13f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Extend `freight.invoices` into the billing record of record for every source + * (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be + * centralized onto it instead of the parallel `warehouse_fee_invoices` table. + * + * Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`), + * a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID` + * statuses the warehouse flow uses. + * + * Matches billing/entities/invoice.entity.ts. All columns are additive with + * defaults, so existing booking/demurrage rows are unaffected. + */ +export class ExtendInvoicesForPartialPayment1828000000000 + implements MigrationInterface +{ + name = "ExtendInvoicesForPartialPayment1828000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long + // as the value is not referenced in the same transaction (it is not here). + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`, + ); + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`, + ); + + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + `); + + // Backfill existing rows: subtotal mirrors the total (no tax was modeled), + // the outstanding balance is the full total for unpaid invoices. + await queryRunner.query(` + UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount; + `); + + // Already-settled invoices: fully paid, zero balance, stamped from updated_at. + await queryRunner.query(` + UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = updated_at + WHERE status = 'PAID'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS payments, + DROP COLUMN IF EXISTS paid_at, + DROP COLUMN IF EXISTS balance_amount, + DROP COLUMN IF EXISTS paid_amount, + DROP COLUMN IF EXISTS tax_amount, + DROP COLUMN IF EXISTS subtotal_amount; + `); + // Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are + // left on freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..dd246cb7d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -0,0 +1,222 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold warehouse fee invoices into the central billing system. + * + * Warehouse fee invoices are no longer a standalone aggregate: each becomes a + * global `freight.invoices` row (`source = 'warehouse'`, `source_id = + * inventory_id`) with its items as `freight.invoice_lines`. The warehouse + * service is now a thin layer over `BillingService`. This migration backfills the + * existing rows (preserving ids, numbers, status, amounts and payment history), + * then drops the two legacy tables. + * + * Rows that cannot be billed centrally — no company to bill (`company_id` / + * `company_profile_id` underivable from the customer or the booking) — are not + * migrated; they could never have been charged through the gateway and are + * dropped with the table. + */ +export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { + name = 'CentralizeWarehouseInvoices1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Invoice headers. Keep the same id so items still link, and so any + // external reference to the invoice id stays valid. + await queryRunner.query(` + INSERT INTO freight.invoices ( + id, invoice_number, company_id, company_profile_id, + subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, status, source, source_id, type, + issued_at, paid_at, payments, payment_id, due_at, + created_at, updated_at, deleted_at + ) + SELECT + fee.id, + fee.invoice_number, + COALESCE(fee.customer_id, b.company_id), + COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ), + fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, + fee.currency, + fee.status::freight.invoices_status_enum, + 'warehouse', + fee.inventory_id, + fee.invoice_type, + fee.issued_at, + fee.paid_at, + COALESCE(fee.payments, '[]'::jsonb), + NULL, + COALESCE(fee.due_date, fee.issued_at, fee.created_at), + fee.created_at, fee.updated_at, fee.deleted_at + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.bookings b ON b.id = fee.booking_id + WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL + AND COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ) IS NOT NULL + ON CONFLICT (id) DO NOTHING; + `); + + // 2. Invoice lines — only for items whose parent invoice migrated. Warehouse + // fee fields (fee_rule_id / chargeable_days / free_days) move into the + // line's jsonb metadata. + await queryRunner.query(` + INSERT INTO freight.invoice_lines ( + id, invoice_id, charge_type, description, quantity, unit_rate, amount, + currency, metadata, created_at, updated_at, deleted_at + ) + SELECT + item.id, + item.invoice_id, + item.fee_type, + item.description, + item.quantity, + item.unit_rate, + item.amount, + item.currency, + jsonb_build_object( + 'feeRuleId', item.fee_rule_id, + 'chargeableDays', item.chargeable_days, + 'freeDays', item.free_days + ), + item.created_at, item.updated_at, item.deleted_at + FROM freight.warehouse_fee_invoice_items item + JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // 3. Drop the legacy tables (items first — FK to invoices). + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate the legacy tables … + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_number varchar(40) NOT NULL, + booking_id uuid, + customer_id uuid, + inventory_id uuid NOT NULL, + facility_id uuid, + warehouse_id uuid, + yard_id uuid, + zone_id uuid, + invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, + tax_amount numeric(14,2) NOT NULL DEFAULT 0, + total_amount numeric(14,2) NOT NULL DEFAULT 0, + paid_amount numeric(14,2) NOT NULL DEFAULT 0, + balance_amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + period_start timestamptz, + period_end timestamptz, + issued_at timestamptz, + due_date timestamptz, + paid_at timestamptz, + cancelled_at timestamptz, + payments jsonb NOT NULL DEFAULT '[]', + notes text, + CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), + CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, + ); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_id uuid NOT NULL, + fee_rule_id uuid, + fee_type varchar(32) NOT NULL, + description varchar(255) NOT NULL, + quantity numeric(12,2) NOT NULL DEFAULT 1, + unit_rate numeric(14,2) NOT NULL DEFAULT 0, + amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + chargeable_days int, + free_days int, + CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), + CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" + FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, + ); + + // … then copy the warehouse-source invoices back, deriving the typed FKs and + // period from the linked inventory item. + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoices ( + id, created_at, updated_at, deleted_at, invoice_number, + booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, + invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes + ) + SELECT + i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, + inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, + i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, + i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, + CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, + i.payments, NULL + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoice_items ( + id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, + description, quantity, unit_rate, amount, currency, chargeable_days, free_days + ) + SELECT + l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, + NULLIF(l.metadata->>'feeRuleId', '')::uuid, + l.charge_type, + COALESCE(l.description, ''), + l.quantity, l.unit_rate, l.amount, l.currency, + NULLIF(l.metadata->>'chargeableDays', '')::int, + NULLIF(l.metadata->>'freeDays', '')::int + FROM freight.invoice_lines l + JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // Remove the migrated rows from the central tables. + await queryRunner.query(` + DELETE FROM freight.invoice_lines + WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); + `); + await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 551fae6bf..dc78cd6e9 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; import { BillingService } from "./billing.service"; +import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; @@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module"; TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), CompaniesModule, + DocumentsModule, ], controllers: [BillingController, PortalBillingController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 0e6d97de0..61597264b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); }); @@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => { expect(invoice.sourceId).toBe("booking-1"); expect(invoice.totalAmount).toBe(1500); expect(invoice.issuedAt).toBeInstanceOf(Date); - expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); @@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -180,6 +183,89 @@ describe("BillingService.markInvoiceAsPaid", () => { }); }); +describe("BillingService.recordPayment", () => { + function serviceFor(invoice: Record | null) { + const mg = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + {} as never, // invoiceDocuments + ); + return { service, mg, events }; + } + + const openInvoice = (overrides: Record = {}) => ({ + id: "inv-1", + status: Freight.InvoiceStatus.Issued, + source: "warehouse", + sourceId: "inv-item-1", + totalAmount: 1000, + paidAmount: 0, + balanceAmount: 1000, + payments: [], + paidAt: null, + ...overrides, + }); + + it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => { + const { service, mg, events } = serviceFor(openInvoice()); + + const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" }); + + expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid); + expect(updated.paidAmount).toBe(400); + expect(updated.balanceAmount).toBe(600); + expect(updated.payments).toHaveLength(1); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + expect.objectContaining({ + status: Freight.InvoiceStatus.PartiallyPaid, + paidAmount: 400, + balanceAmount: 600, + }), + ); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { + const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 })); + + const updated = await service.recordPayment("inv-1", { amount: 600 }); + + expect(updated.status).toBe(Freight.InvoiceStatus.Paid); + expect(updated.balanceAmount).toBe(0); + expect(updated.paidAt).toBeInstanceOf(Date); + expect(mg.update).toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith( + "warehouse.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), + ); + }); + + it("rejects a non-positive amount", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects payment against a cancelled invoice", async () => { + const { service, mg } = serviceFor( + openInvoice({ status: Freight.InvoiceStatus.Cancelled }), + ); + await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); +}); + describe("BillingService.settlePayable", () => { it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { const open = { @@ -200,6 +286,7 @@ describe("BillingService.settlePayable", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); const settled = await service.settlePayable( @@ -234,6 +321,7 @@ describe("BillingService.settlePayable", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); 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 01b057a76..f1b58ad5e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,15 +1,28 @@ -import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { EventEmitter2 } from "@nestjs/event-emitter"; import { Freight, PaymentReferenceType } from "@edr/types"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice } from "./entities/invoice.entity"; -import { InvoiceLine } from "./entities/invoice-line.entity"; -import { InvoiceRepository } from "./invoice.repository"; -import { InvoiceLineRepository } from "./invoice-line.repository"; +import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto } from "../payment/payments.dto"; -import { CompaniesService } from "../companies/companies.service"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "./documents/invoice-document.service"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ export interface PayInvoiceOptions { @@ -20,13 +33,26 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** A single manual/offline settlement to record against an invoice. */ +export interface RecordPaymentInput { + /** Amount settled by this payment; must be > 0. */ + amount: number; + method?: string | null; + reference?: string | null; + /** When the settlement occurred; defaults to now. */ + paidAt?: Date; + metadata?: Record | null; +} + /** Default invoice payment-term window, in days, used to compute `dueAt`. */ const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; @@ -56,7 +82,11 @@ export interface GenerateInvoiceInput { companyProfileId: string; lines: InvoiceLineInput[]; currency?: string; - /** Explicit total; defaults to the sum of line amounts. */ + /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ + subtotalAmount?: number; + /** Tax applied on top of the subtotal; defaults to 0. */ + taxAmount?: number; + /** Explicit total; defaults to `subtotalAmount + taxAmount`. */ totalAmount?: number; /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ dueAt?: Date; @@ -95,6 +125,7 @@ export class BillingService { @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, private readonly companies: CompaniesService, + private readonly invoiceDocuments: InvoiceDocumentService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -115,6 +146,69 @@ export class BillingService { return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; } + // ── Documents (central PDF) ────────────────────────────────────────────────── + + /** Sealed PDF invoice for any source, rendered by the shared document service. */ + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE")); + } + + /** Sealed PDF receipt; available once any payment has been recorded. */ + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException("A receipt is available only after payment is recorded."); + } + return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT")); + } + + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ + private toDocumentModel( + invoice: Invoice & { lines: InvoiceLine[] }, + kind: "INVOICE" | "RECEIPT", + ): InvoiceDocumentModel { + const title = invoice.source + ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) + : "EDR"; + const totals: InvoiceDocumentModel["totals"] = [ + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + ]; + if (Number(invoice.taxAmount) > 0) { + totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); + } + totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true }); + totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); + totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + + return { + kind, + title, + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null }, + { label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null }, + ], + categoryHeader: "Charge type", + lines: invoice.lines.map((l) => ({ + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + })), + totals, + }; + } + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── /** Resolve the customer's company id from their IAM user id (null if none). */ @@ -175,18 +269,9 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ - private async nextInvoiceNumber(mg: EntityManager): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; - const prefix = `FRT-${ymd}-`; - const [row] = await mg.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, "0")}`; + /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ + private nextInvoiceNumber(mg: EntityManager): Promise { + return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); } /** @@ -230,8 +315,12 @@ export class BillingService { }; }); + const subtotalAmount = + input.subtotalAmount ?? + lines.reduce((sum, l) => sum + Number(l.amount), 0); + const taxAmount = input.taxAmount ?? 0; const totalAmount = - input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? @@ -250,7 +339,12 @@ export class BillingService { type: input.type, companyId: input.companyId, companyProfileId: input.companyProfileId, - totalAmount, + subtotalAmount: round2(subtotalAmount), + taxAmount: round2(taxAmount), + totalAmount: round2(totalAmount), + paidAmount: 0, + balanceAmount: round2(totalAmount), + payments: [], currency, status, issuedAt: issued ? new Date() : null, @@ -293,6 +387,85 @@ export class BillingService { ); } + /** + * Record a (possibly partial) settlement against an invoice and sync its + * status. Appends to the `payments` ledger, recomputes `paidAmount` / + * `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the + * balance reaches zero — PAID, stamping `paidAt` and emitting + * `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash + * at the warehouse counter); gateway settlement goes through + * {@link markInvoiceAsPaid}. + * + * Throws when the invoice is missing, cancelled, refunded, already fully paid, + * or when `amount` is not positive. Pass `manager` to enlist in a caller's + * transaction. + */ + async recordPayment( + invoiceId: string, + input: RecordPaymentInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount > 0)) { + throw new BadRequestException("Payment amount must be greater than zero."); + } + + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + + const at = input.paidAt ?? new Date(); + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; + + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + await mg.update( + Invoice, + { id: invoice.id }, + { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : invoice.paidAt ?? null, + } as never, + ); + + const updated = { + ...invoice, + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : invoice.paidAt ?? null, + } as Invoice; + + if (fullyPaid) this.emitInvoiceEvent("paid", updated); + return updated; + } + /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. * No-op when already refunded. @@ -487,11 +660,12 @@ export class BillingService { const result = await this.payment.initiate({ referenceId: sourceId, source: invoice.source, - // Gateway reference type derives from the invoice source by convention - // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and - // the domain never supplies it. New sources add their uppercased value to - // the PaymentReferenceType enum. - referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + // Freight payments settle under the generic SHIPMENT reference — how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts new file mode 100644 index 000000000..c320a5d44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; + +import { InvoiceDocumentService } from "./invoice-document.service"; +import { PdfRenderService } from "./pdf-render.service"; + +/** + * Standalone document infrastructure — generic HTML→PDF plus the shared + * invoice/receipt renderer. Has no domain dependencies, so any module (billing, + * warehouses, …) can import it to print invoices without coupling to the + * billing payment graph. + */ +@Module({ + providers: [PdfRenderService, InvoiceDocumentService], + exports: [PdfRenderService, InvoiceDocumentService], +}) +export class DocumentsModule {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts new file mode 100644 index 000000000..a07087f8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from "@nestjs/common"; + +import { PdfRenderService } from "./pdf-render.service"; + +export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; + +/** One billed line on the document (charge type / fee type agnostic). */ +export interface InvoiceDocumentLine { + description: string | null; + /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */ + category?: string | null; + quantity?: number | null; + unitRate?: number | null; + amount?: number | null; + currency?: string | null; +} + +/** A labelled total row in the totals box; mark `grand` for the headline total. */ +export interface InvoiceDocumentTotal { + label: string; + amount: number; + grand?: boolean; +} + +/** + * Source-agnostic description of a printable invoice/receipt. Each billing + * source maps its own entity onto this shape; the renderer owns the layout so + * every EDR invoice document looks identical regardless of source. + */ +export interface InvoiceDocumentModel { + kind: InvoiceDocumentKind; + /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */ + title: string; + documentNumber: string; + issuedAt?: Date | string | null; + status: string; + currency: string; + /** Free-form summary grid (label/value pairs). */ + summary: Array<{ label: string; value: string | null }>; + /** Header for the line-item category column; column hidden when omitted. */ + categoryHeader?: string; + lines: InvoiceDocumentLine[]; + totals: InvoiceDocumentTotal[]; + /** Override the round seal text; defaults from kind/status. */ + sealText?: string; +} + +/** + * Central invoice/receipt PDF renderer shared by every billing source. Turns a + * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it + * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in + * `WarehouseInvoiceService`; it now serves all invoices. + */ +@Injectable() +export class InvoiceDocumentService { + constructor(private readonly pdf: PdfRenderService) {} + + async render( + model: InvoiceDocumentModel, + ): Promise<{ filename: string; buffer: Buffer }> { + const html = this.buildHtml(model); + const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; + return { + filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), + }; + } + + buildHtml(model: InvoiceDocumentModel): string { + const esc = (value: unknown) => + String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const money = (amount: unknown, currency = model.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const showCategory = Boolean(model.categoryHeader); + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + + const summaryRows = model.summary + .map((row) => `
${esc(row.label)}${esc(row.value)}
`) + .join(""); + + const itemRows = model.lines + .map( + (item) => ` + ${esc(item.description)} + ${showCategory ? `${esc((item.category ?? "").replace(/_/g, " "))}` : ""} + ${esc(item.quantity ?? 0)} + ${esc(money(item.unitRate, item.currency ?? model.currency))} + ${esc(money(item.amount, item.currency ?? model.currency))} + `, + ) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount))}
`, + ) + .join(""); + + return ` + + + + ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

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

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+ + + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+
${totalRows}
+ +
+ +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -0,0 +1,160 @@ +import { existsSync } from "fs"; + +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +export interface PdfRenderOptions { + /** Label used in logs to identify the document kind. */ + label?: string; + /** + * Degraded renderer used when Chromium is unavailable. Receives the + * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` + * header). When omitted, a generic single-page fallback is produced. + */ + fallback?: (preparedHtml: string) => Buffer; +} + +/** + * Generic HTML → PDF renderer shared by every document producer (invoices, + * receipts, warehouse release orders). Renders via headless Chromium when + * available and degrades to a caller-supplied (or generic) hand-built PDF + * otherwise. This is pure infrastructure — it knows nothing about invoices. + */ +@Injectable() +export class PdfRenderService { + private readonly logger = new Logger(PdfRenderService.name); + + async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise { + const label = opts.label ?? "document"; + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import("puppeteer"); + const launchOptions: import("puppeteer").LaunchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); + await page.emulateMediaType("print"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`); + } + this.logger.log( + `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`, + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes("edr-pdf-print-fix")) return html; + if (html.includes("")) { + return html.replace("", `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + ]; + return candidates.find((path) => existsSync(path)); + } + + isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + + /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */ + private genericFallbackPdf(html: string): Buffer { + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + + const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40); + const stream = + "BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" + + lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") + + "ET"; + + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n"; + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 61bc9c16b..23c332f80 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; +/** A single recorded settlement against an invoice (payment ledger entry). */ +export interface InvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + /** ISO timestamp of when the settlement was recorded. */ + paidAt: string; + metadata?: Record | null; +} + @Entity({ schema: "freight", name: "invoices" }) @Index(["companyId"]) @Index(["companyProfileId"]) @@ -28,9 +38,24 @@ export class Invoice extends BaseEntity { @JoinColumn({ name: "company_profile_id" }) companyProfile?: CompanyProfile; + /** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */ + @Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) totalAmount!: number; + /** Cumulative amount settled so far (supports partial payment). */ + @Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + /** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */ + @Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -62,6 +87,14 @@ export class Invoice extends BaseEntity { @Column({ name: "issued_at", type: "timestamptz", nullable: true }) issuedAt?: Date | null; + /** Set when the invoice is fully settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Ledger of individual settlements (manual or gateway), newest last. */ + @Column({ name: "payments", type: "jsonb", default: () => "'[]'" }) + payments!: InvoicePayment[]; + /** The ID of the payment that generated this invoice. */ @Column({ name: "payment_id", type: "uuid", nullable: true }) paymentId?: string | null; diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d36788600 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -0,0 +1,44 @@ +/** + * Shared per-day sequential invoice numbering, used by every billing source + * (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the + * `MAX(seq)+1` allocation live in one place instead of being copy-pasted per + * service. + * + * Produces `-YYYYMMDD-00001`: the sequence is the max existing suffix for + * the day + 1. Run inside the caller's transaction (pass that transaction's + * manager) so concurrent generation within a transaction stays consistent. + */ + +/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ +export interface SqlRunner { + query(sql: string, params?: unknown[]): Promise>; +} + +export interface InvoiceNumberOptions { + /** Schema-qualified table to scan, e.g. `freight.invoices`. */ + table: string; + /** Document code prefix, e.g. `FRT` or `WHF`. */ + code: string; + /** Column holding the number; defaults to `invoice_number`. */ + column?: string; + /** Clock injection point (tests); defaults to now. */ + now?: Date; +} + +export async function nextDailyInvoiceNumber( + runner: SqlRunner, + opts: InvoiceNumberOptions, +): Promise { + const now = opts.now ?? new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `${opts.code}-${ymd}-`; + const column = opts.column ?? "invoice_number"; + + const [row] = await runner.query( + `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq + FROM ${opts.table} WHERE ${column} LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -0,0 +1,36 @@ +/** + * Shared payment/settlement math for invoices. Both the global + * `BillingService.recordPayment` and the warehouse fee invoice flow apply a + * payment the same way — accumulate `paidAmount`, derive the outstanding + * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping + * it here means the two flows can never drift on rounding or the + * partial-vs-full threshold. + */ + +/** Round to 2 decimals, avoiding binary float drift. */ +export const round2 = (n: number): number => Math.round(n * 100) / 100; + +export interface SettlementResult { + /** New cumulative amount paid. */ + paidAmount: number; + /** Remaining balance (0 once fully paid). */ + balanceAmount: number; + /** True once the balance reaches zero. */ + fullyPaid: boolean; +} + +/** + * Apply a single payment of `amount` to an invoice with `totalAmount` already + * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and + * the invoice being in a payable state. + */ +export function applySettlement( + totalAmount: number, + currentPaid: number, + amount: number, +): SettlementResult { + const total = Number(totalAmount); + const paidAmount = round2(Number(currentPaid) + Number(amount)); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -6,8 +6,6 @@ import { ParseUUIDPipe, Query, Res, - Body, - Post, } from "@nestjs/common"; import { ApiTags, @@ -18,9 +16,9 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { IntentStatusDto, RefundDto } from "./payments.dto"; +import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,13 +71,6 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } - @Post("refund") - @FreightAdmin() - @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) - refund(@Body() dto: RefundDto) { - return this.paymentService.refund(dto); - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..d92af7a3e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,13 +1,12 @@ import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,75 +15,70 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; +import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { - ClientAction, - ProviderPaymentStatus, -} from "@edr/payment-providers"; -import { - PaymentService as PaymentServiceEnum, - PaymentReferenceType, - PaymentIntentSnapshot, - ProviderMethod, + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, } from "@edr/types"; import { - InitiateResponseDto, - IntentStatusDto, - PaymentPlatformDto, - RefundDto, + InitiateResponseDto, + IntentStatusDto, + PaymentPlatformDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by * the caller (billing) — this service never derives them from a domain record. */ export interface InitiateIntentInput { - /** Opaque domain reference (booking id, …). */ - referenceId: string; - /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ - source: string; - /** Gateway reference type the intent is opened with (caller's domain decides it). */ - referenceType: PaymentReferenceType; - /** Human-readable order ref shown on provider pages. */ - orderRef: string; - /** Authoritative amount in minor units, computed by the caller. */ - amountMinor: number; - currency: string; - /** Stored on the intent projection for receipts/dashboards. */ - reason?: string; - /** Provider/method selector. */ - method: ProviderMethod | string; - platform?: PaymentPlatformDto; - payerAccount?: string; - returnUrl?: string; - failureUrl?: string; + /** Opaque domain reference (booking id, …). */ + referenceId: string; + /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ + source: string; + /** Gateway reference type the intent is opened with (caller's domain decides it). */ + referenceType: PaymentReferenceType; + /** Human-readable order ref shown on provider pages. */ + orderRef: string; + /** Authoritative amount in minor units, computed by the caller. */ + amountMinor: number; + currency: string; + /** Stored on the intent projection for receipts/dashboards. */ + reason?: string; + /** Provider/method selector. */ + method: ProviderMethod | string; + platform?: PaymentPlatformDto; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; } export interface InitiateIntentResult { - intentId: string; - response: InitiateResponseDto; - /** True when the provider settled the charge synchronously during initiate. */ - immediateSuccess: boolean; - providerTxnId?: string; - paidAt?: Date; + intentId: string; + response: InitiateResponseDto; + /** True when the provider settled the charge synchronously during initiate. */ + immediateSuccess: boolean; + providerTxnId?: string; + paidAt?: Date; } const STATUS_MAP: Record = { - "action-required": ProviderPaymentStatus.REQUIRES_ACTION, - "processing": ProviderPaymentStatus.PROCESSING, - "success": ProviderPaymentStatus.SUCCEEDED, - "failed": ProviderPaymentStatus.FAILED, - "canceled": ProviderPaymentStatus.CANCELLED, - "refunded": ProviderPaymentStatus.CANCELLED, + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + processing: ProviderPaymentStatus.PROCESSING, + success: ProviderPaymentStatus.SUCCEEDED, + failed: ProviderPaymentStatus.FAILED, + canceled: ProviderPaymentStatus.CANCELLED, + refunded: ProviderPaymentStatus.CANCELLED, }; const PROVIDER_TO_METHOD: Record = { - TELEBIRR: "telebirr", - CBE_BIRR: "cbe-birr", - EBIRR: "ebirr", - WAAFI: "waafi", - CARD: "card", - DMONEY: "dmoney", - CAC_BANK: "cac-bank", + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; /** @@ -96,426 +90,460 @@ const PROVIDER_TO_METHOD: Record = { */ @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); + private readonly logger = new Logger(PaymentService.name); - constructor( - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BillingService)) - private readonly billing: BillingService, - ) { } + constructor( + private readonly paymentRepo: PaymentRepository, + private readonly paymentClient: PaymentClientService, + @Inject(forwardRef(() => BillingService)) + private readonly billing: BillingService, + ) { } - async getAll(filters: { - search?: string; - status?: string; - method?: string; - page?: number; - pageSize?: number; - }) { - const { search, status, method, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { + const { search, status, method, page = 1, pageSize = 10 } = filters; + const skip = (page - 1) * pageSize; - const qb = this.paymentRepo.createQueryBuilder("payment"); + const qb = this.paymentRepo.createQueryBuilder("payment"); - if (search) { - qb.andWhere( - "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", - { search: `%${search}%` }, - ); - } - if (status) { - qb.andWhere("payment.status = :status", { status }); - } - if (method) { - qb.andWhere("payment.method = :method", { method }); - } + if (search) { + qb.andWhere( + "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", + { search: `%${search}%` }, + ); + } + if (status) { + qb.andWhere("payment.status = :status", { status }); + } + if (method) { + qb.andWhere("payment.method = :method", { method }); + } - const [items, total] = await qb - .orderBy("payment.createdAt", "DESC") - .skip(skip) - .take(pageSize) - .getManyAndCount(); + const [items, total] = await qb + .orderBy("payment.createdAt", "DESC") + .skip(skip) + .take(pageSize) + .getManyAndCount(); + return { + items: items.map((p) => ({ + id: p.id, + bookingId: p.refId, + amount: p.amount, + currency: p.currency, + method: p.method, + status: p.status, + merchantOrderId: p.merchantOrderId, + paidAt: p.paidAt, + createdAt: p.createdAt, + })), + total, + page, + pageSize, + }; + } + + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + + /** + * Open a gateway intent for a caller-supplied amount/reference and project it + * locally. Returns the intent id (so billing can correlate the invoice) plus + * the client action. When the provider settles synchronously, the intent is + * marked paid WITHOUT emitting — the caller (billing) settles inline after it + * has stored the intent id, avoiding a settle-before-correlation race. + */ + async initiate(input: InitiateIntentInput): Promise { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); + + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + + const intent = await this.upsertIntent(input, snapshot); + + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, + }); + } + + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, + providerTxnId: snapshot.providerTxnId, + paidAt, + }; + } + + /** Create or update the local intent projection from a provider snapshot. */ + private async upsertIntent( + input: InitiateIntentInput, + snapshot: PaymentIntentSnapshot, + ): Promise { + const existing = await this.paymentRepo.findOneBy({ + refId: input.referenceId, + }); + + const method: PaymentEntity["method"] = + PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); + + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; + const data = { + status, + method, + merchantOrderId: + snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", + transactionId: snapshot.providerTxnId ?? existing?.transactionId, + expiresAt: snapshot.expiresAt + ? new Date(snapshot.expiresAt) + : existing?.expiresAt, + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }; + + if (existing) { + await this.paymentRepo.update({ id: existing.id }, { + ...data, + clientAction, + } as any); + return { ...existing, ...data, clientAction } as PaymentEntity; + } + + return this.paymentRepo.create({ + refId: input.referenceId, + type: input.source, + referenceType: input.referenceType, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, + rawInitiation: snapshot as unknown as Record, + clientAction: clientAction ?? {}, + ...data, + } as any); + } + + /** + * Reconcile an intent's status with the gateway by reference. Read-only on the + * domain side: it syncs the local projection and, when the provider reports a + * newly-observed success, notifies billing to settle. `referenceId` is opaque + * (the booking id, but this service does not load it). + */ + async getIntentByBookingId(referenceId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: referenceId }); + + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + (local?.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + referenceId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, + ); + } + + if (!snapshot) { + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + if (!local) throw new NotFoundException("PaymentIntent not found"); + + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && + local.status !== "success"; + + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, + }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; + + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { + status: "success", + paidAt, + transactionId: opts.providerTxnId ?? intent.transactionId, + }, + ); + + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId, + paidAt, + ); + } + + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success" || intent.status === "canceled") return; + + await this.paymentRepo.update( + { id: intent.id }, + { + status: "failed", + failerCode: input.failureCode, + failureMessage: input.failureMessage, + }, + ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async getActivePaymentByOrderIdAndMethod( + orderId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) + throw new BadRequestException( + "No successful payment found for this order", + ); + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); + + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + return template({ + vendorName: "Ethio Djibouti Railway Freight Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment.method, + subtotal: payment.amount.toString(), + total: payment.amount.toString(), + currency: payment.currency, + reason: payment.reason, + }); + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id }); + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + return { + intentId: intent.id, + status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } + + private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { + return { + ...this.formatIntentResponse(intent), + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failerCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; + } + + async handlePaymentEvent(event: { + eventType: string; + eventId: string; + referenceId: string; + intentId: string; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }): Promise<{ + processed: boolean; + alreadyFinalized?: boolean; + reason?: string; + }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - items: items.map((p) => ({ - id: p.id, - bookingId: p.refId, - amount: p.amount, - currency: p.currency, - method: p.method, - status: p.status, - merchantOrderId: p.merchantOrderId, - paidAt: p.paidAt, - createdAt: p.createdAt, - })), - total, - page, - pageSize, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + console.log(`Processing payment succeeded event for intent: }`, intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, + }); + console.log( + `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, + ); + + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. + return { processed: true, alreadyFinalized }; } - /** Aggregate counts across ALL payments for the dashboard summary cards. */ - async getSummary() { - const rows = await this.paymentRepo - .createQueryBuilder("payment") - .select("payment.status", "status") - .addSelect("COUNT(*)::int", "count") - .groupBy("payment.status") - .getRawMany<{ status: string; count: number }>(); - - const byStatus: Record = {}; - let total = 0; - for (const row of rows) { - byStatus[row.status] = row.count; - total += row.count; - } - - const paidAgg = await this.paymentRepo - .createQueryBuilder("payment") - .select("COALESCE(SUM(payment.amount), 0)", "sum") - .where("payment.status = :status", { status: "success" }) - .getRawOne<{ sum: string }>(); - + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - total, - success: byStatus["success"] ?? 0, - processing: - (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), - failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), - refunded: byStatus["refunded"] ?? 0, - paidAmount: Number(paidAgg?.sum ?? 0), + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; } - /** - * Open a gateway intent for a caller-supplied amount/reference and project it - * locally. Returns the intent id (so billing can correlate the invoice) plus - * the client action. When the provider settles synchronously, the intent is - * marked paid WITHOUT emitting — the caller (billing) settles inline after it - * has stored the intent id, avoiding a settle-before-correlation race. - */ - async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: input.referenceType, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); + return { + processed: false, + reason: `Unknown event type: ${event.eventType}`, + }; + } - const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - - const intent = await this.upsertIntent(input, snapshot); - - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { - providerTxnId: snapshot.providerTxnId, - paidAt, - notify: false, - }); - } - - return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, - }; + private toLocalStatus( + status: ProviderPaymentStatus, + ): PaymentEntity["status"] { + switch (status) { + case ProviderPaymentStatus.SUCCEEDED: + return "success"; + case ProviderPaymentStatus.FAILED: + return "failed"; + case ProviderPaymentStatus.CANCELLED: + return "canceled"; + case ProviderPaymentStatus.PROCESSING: + return "processing"; + default: + return "action-required"; } + } - /** Create or update the local intent projection from a provider snapshot. */ - private async upsertIntent( - input: InitiateIntentInput, - snapshot: PaymentIntentSnapshot, - ): Promise { - const existing = await this.paymentRepo.findOneBy({ - refId: input.referenceId, - }); - - const method: PaymentEntity["method"] = - PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = - snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); - - const clientAction = (snapshot.clientAction ?? undefined) as - | Record - | undefined; - const data = { - status, - method, - merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", - transactionId: snapshot.providerTxnId ?? existing?.transactionId, - expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt, - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }; - - if (existing) { - await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any); - return { ...existing, ...data, clientAction } as PaymentEntity; - } - - return this.paymentRepo.create({ - refId: input.referenceId, - type: input.source, - referenceType: input.referenceType, - amount: input.amountMinor, - currency: input.currency as PaymentEntity["currency"], - reason: input.reason ?? `Payment for ${input.orderRef}`, - rawInitiation: snapshot as unknown as Record, - clientAction: clientAction ?? {}, - ...data, - } as any); - } - - /** - * Reconcile an intent's status with the gateway by reference. Read-only on the - * domain side: it syncs the local projection and, when the provider reports a - * newly-observed success, notifies billing to settle. `referenceId` is opaque - * (the booking id, but this service does not load it). - */ - async getIntentByBookingId(referenceId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: referenceId }); - - let snapshot: PaymentIntentSnapshot | null = null; - try { - snapshot = await this.paymentClient.getIntentByReference( - (local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT, - referenceId, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, - ); - } - - if (!snapshot) { - if (!local) throw new NotFoundException("PaymentIntent not found"); - return this.formatIntentStatus(local); - } - if (!local) throw new NotFoundException("PaymentIntent not found"); - - // Sync local projection with provider-reported status. - const becameSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - - if (becameSuccess) { - await this.markIntentSucceeded(local.id, { - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - notify: true, - }); - } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { - await this.paymentRepo.update( - { id: local.id }, - { - status: this.toLocalStatus(snapshot.status), - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }, - ); - } - - const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); - return this.formatIntentStatus(refreshed ?? local); - } - - /** - * Mark a gateway intent paid and (by default) notify billing to settle the - * linked invoice. Idempotent — no-op when already success. Pass `notify: false` - * when the caller settles inline and will trigger settlement itself. - */ - async markIntentSucceeded( - intentId: string, - opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, - ): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; - - const paidAt = opts.paidAt ?? new Date(); - await this.paymentRepo.update( - { id: intent.id }, - { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, - ); - - if (opts.notify !== false) { - await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); - } - - return { alreadyFinalized: false }; - } - - async markPaymentFailed(input: { - intentId: string; - failureCode?: string; - failureMessage?: string; - }): Promise { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success" || intent.status === "canceled") return; - - await this.paymentRepo.update( - { id: intent.id }, - { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, - ); - - // Invoice stays open for retry — nothing to settle. Logged only. - this.logger.warn( - `Payment ${intent.id} failed for ${intent.refId}` + - (input.failureMessage ? `: ${input.failureMessage}` : ""), - ); - } - - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" }); - if (!payment) throw new BadRequestException("No successful payment found for this order"); - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); - - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - return template({ - vendorName: "Ethio Djibouti Railway Freight Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment.method, - subtotal: payment.amount.toString(), - total: payment.amount.toString(), - currency: payment.currency, - reason: payment.reason, - }); - } - - findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id }); - } - - formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { - const clientAction = - intent.clientAction && typeof intent.clientAction === "object" - ? (intent.clientAction as unknown as ClientAction) - : undefined; - return { - intentId: intent.id, - status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, - clientAction, - merchantOrderId: intent.merchantOrderId ?? undefined, - }; - } - - private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { - return { - ...this.formatIntentResponse(intent), - paidAt: intent.paidAt?.toISOString(), - failureCode: intent.failerCode ?? undefined, - failureMessage: intent.failureMessage ?? undefined, - }; - } - - async handlePaymentEvent(event: { - eventType: string; - eventId: string; - referenceId: string; - intentId: string; - providerTxnId?: string; - paidAt?: string; - failureCode?: string; - failureMessage?: string; - }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); - if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - console.log(`Processing payment succeeded event for intent: }`,intent); - const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { - providerTxnId: event.providerTxnId, - paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - notify: true, - }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - return { processed: true, alreadyFinalized }; - } - - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } - - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; - } - - private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { - switch (status) { - case ProviderPaymentStatus.SUCCEEDED: return "success"; - case ProviderPaymentStatus.FAILED: return "failed"; - case ProviderPaymentStatus.CANCELLED: return "canceled"; - case ProviderPaymentStatus.PROCESSING: return "processing"; - default: return "action-required"; - } - } - - async findByCompanyId(companyId: string) { - return this.paymentRepo.findByCompanyId(companyId); - } + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts deleted file mode 100644 index 8b14dcea3..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; - -import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; - -export const WAREHOUSE_FEE_TYPES = [ - 'CONTAINER_DEMURRAGE', - 'BULK_DEMURRAGE', - 'STORAGE_FEE', - 'HANDLING_FEE', -] as const; -export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; - -@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) -@Index(['invoiceId']) -export class WarehouseFeeInvoiceItem extends BaseEntity { - @Column({ name: 'invoice_id', type: 'uuid' }) - invoiceId!: string; - - @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'invoice_id' }) - invoice?: WarehouseFeeInvoice; - - @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) - feeRuleId?: string | null; - - @Column({ name: 'fee_type', type: 'varchar', length: 32 }) - feeType!: WarehouseFeeType; - - @Column({ name: 'description', type: 'varchar', length: 255 }) - description!: string; - - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) - quantity!: number; - - @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) - unitRate!: number; - - @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - amount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - @Column({ name: 'chargeable_days', type: 'int', nullable: true }) - chargeableDays?: number | null; - - @Column({ name: 'free_days', type: 'int', nullable: true }) - freeDays?: number | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts deleted file mode 100644 index e57d626d5..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; -export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; - -export const WAREHOUSE_INVOICE_STATUSES = [ - 'DRAFT', - 'ISSUED', - 'PARTIALLY_PAID', - 'PAID', - 'CANCELLED', -] as const; -export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; - -/** A single recorded payment against a warehouse fee invoice (history). */ -export interface WarehouseInvoicePayment { - amount: number; - method?: string | null; - reference?: string | null; - paidAt: string; -} - -/** - * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. - * Owns warehouse fees; links to booking/customer/inventory/location so it can - * connect to the existing payment module without duplicating it. - */ -@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) -@Index(['invoiceNumber'], { unique: true }) -@Index(['bookingId']) -@Index(['inventoryId']) -@Index(['status']) -export class WarehouseFeeInvoice extends BaseEntity { - @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) - invoiceNumber!: string; - - @Column({ name: 'booking_id', type: 'uuid', nullable: true }) - bookingId?: string | null; - - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId?: string | null; - - @Column({ name: 'inventory_id', type: 'uuid' }) - inventoryId!: string; - - @Column({ name: 'facility_id', type: 'uuid', nullable: true }) - facilityId?: string | null; - - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId?: string | null; - - @Column({ name: 'yard_id', type: 'uuid', nullable: true }) - yardId?: string | null; - - @Column({ name: 'zone_id', type: 'uuid', nullable: true }) - zoneId?: string | null; - - @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) - invoiceType!: WarehouseInvoiceType; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) - status!: WarehouseInvoiceStatus; - - @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - subtotalAmount!: number; - - @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - taxAmount!: number; - - @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - totalAmount!: number; - - @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - paidAmount!: number; - - @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - balanceAmount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ - @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) - periodStart?: Date | null; - - @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) - periodEnd?: Date | null; - - @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) - issuedAt?: Date | null; - - @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) - dueDate?: Date | null; - - @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) - paidAt?: Date | null; - - @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) - cancelledAt?: Date | null; - - @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) - payments!: WarehouseInvoicePayment[]; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts deleted file mode 100644 index 5b5df396e..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; - -@Injectable() -export class WarehouseFeeInvoiceItemRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts deleted file mode 100644 index 97328f46d..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; - -@Injectable() -export class WarehouseFeeInvoiceRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 1fe184662..6f7219781 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,17 +1,24 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; -import { NotificationsService } from '../notifications/notifications.service'; +import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { - WarehouseFeeInvoice, + InvoiceDocumentModel, + InvoiceDocumentService, +} from '../billing/documents/invoice-document.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { WarehouseFeeService } from './warehouse-fee.service'; +import { + WarehouseFeeInvoiceView, + WarehouseFeeType, + WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; -import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; +} from './warehouse-invoice.types'; interface GenerateOptions { confirmZero?: boolean; @@ -27,9 +34,18 @@ export interface PayInvoiceDto { driverPhone?: string; } -/** Invoices that still owe money and therefore block terminal release. */ -const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; -const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; +/** Warehouse fee invoices live in the global billing system under this source. */ +const SOURCE = Freight.InvoiceSource.Warehouse; + +/** Global statuses that still owe money and therefore block terminal release. */ +const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, + Freight.InvoiceStatus.Overdue, +]; +/** Global statuses considered an "active" invoice for per-inventory dedup. */ +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails { zoneName: string | null; } -export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; +export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView & + Partial & { items: WarehouseInvoiceItemView[] }; +/** The warehouse-specific columns derived from the linked inventory item. */ +interface InventoryContext { + bookingId: string | null; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + periodStart: Date | null; +} + +/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */ +interface ViewSource { + id: string; + invoiceNumber: string; + companyId: string; + sourceId: string; + type: string; + status: Freight.InvoiceStatus | string; + subtotalAmount: number | string; + taxAmount: number | string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + currency: string; + issuedAt?: Date | null; + dueAt?: Date | null; + paidAt?: Date | null; + createdAt: Date; + updatedAt: Date; + payments?: Array<{ + amount: number | string; + method?: string | null; + reference?: string | null; + paidAt: string; + }> | null; +} + +/** + * Thin warehouse layer over the central {@link BillingService}. Warehouse fee + * invoices are global `Invoice` rows (`source = warehouse`, `sourceId = + * inventoryId`); this service owns only the warehouse-specific concerns — + * computing fees, per-inventory dedup, release-blocking, SMS notifications, the + * sealed PDF, and reshaping the global invoice back into the historical + * `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering, + * status, and payment math live in billing. + */ @Injectable() export class WarehouseInvoiceService { private readonly logger = new Logger(WarehouseInvoiceService.name); constructor( private readonly dataSource: DataSource, - private readonly invoiceRepository: WarehouseFeeInvoiceRepository, - private readonly itemRepository: WarehouseFeeInvoiceItemRepository, + private readonly billing: BillingService, + private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, - private readonly documents: WarehouseReleaseDocumentService, private readonly notifications: NotificationsService, ) {} // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", w.facility_id AS "facilityId", - b.company_id AS "customerId", b.freight_type AS "freightType" + b.company_id AS "companyId", b.company_profile_id AS "companyProfileId", + b.freight_type AS "freightType" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -75,9 +138,16 @@ export class WarehouseInvoiceService { ); if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + // Routing through the global invoice requires a billable company + profile, + // both of which come from the inventory's booking. + if (!item.companyId || !item.companyProfileId) { + throw new BadRequestException( + 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', + ); + } + // Dedup: only one active (non-cancelled) invoice per inventory item. - const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); - if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { + if (await this.hasActiveInvoice(inventoryId)) { throw new ConflictException( 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', ); @@ -112,9 +182,7 @@ export class WarehouseInvoiceService { }; }); - const subtotal = items.reduce((s, i) => s + i.amount, 0); - const total = subtotal; // tax model can be layered on later - + const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { throw new BadRequestException('No payable warehouse fee found for this item.'); } @@ -124,74 +192,81 @@ export class WarehouseInvoiceService { const invoiceType: WarehouseInvoiceType = hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; - const currency = billingCurrency; - const now = new Date(); - const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; + const lines: InvoiceLineInput[] = items.map((it) => ({ + chargeType: it.feeType, + description: it.description, + quantity: it.quantity, + unitRate: it.unitRate, + amount: it.amount, + currency: it.currency, + metadata: { + feeRuleId: it.feeRuleId ?? null, + chargeableDays: it.chargeableDays ?? null, + freeDays: it.freeDays ?? null, + }, + })); - const invoice = await this.invoiceRepository.create({ - invoiceNumber: await this.nextInvoiceNumber(), - bookingId: item.bookingId ?? null, - customerId: item.customerId ?? null, - inventoryId, - facilityId: item.facilityId ?? null, - warehouseId: item.warehouseId ?? null, - yardId: item.yardId ?? null, - zoneId: item.zoneId ?? null, - invoiceType, - status: 'ISSUED', - subtotalAmount: subtotal, - taxAmount: 0, - totalAmount: total, - paidAmount: 0, - balanceAmount: total, - currency, - periodStart: item.arrivedAt ?? null, - periodEnd, - issuedAt: now, - payments: [], - notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, + const invoice = await this.billing.generateInvoice({ + source: SOURCE, + sourceId: inventoryId, + type: invoiceType, + companyId: item.companyId, + companyProfileId: item.companyProfileId, + currency: billingCurrency, + lines, + status: Freight.InvoiceStatus.Issued, }); - for (const it of items) { - await this.itemRepository.create({ invoiceId: invoice.id, ...it }); - } - - const saved = await this.findById(invoice.id); - await this.notifyWarehouseFeeIssued(saved); - return saved; - } - - /** WHF-YYYYMMDD-00001 — sequential per day. */ - private async nextInvoiceNumber(): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const prefix = `WHF-${ymd}-`; - const [row] = await this.dataSource.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, '0')}`; + const detail = await this.findById(invoice.id); + await this.notifyWarehouseFeeIssued(detail); + return detail; } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); + async findById(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + const ctx = await this.getInventoryContext(invoice.sourceId); const details = await this.getInvoiceDocumentDetails(invoice); - return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] }; + const items = invoice.lines.map((l) => this.lineToItem(l)); + return { ...this.buildView(invoice, ctx), ...details, items }; + } + + listForInventory(inventoryId: string): Promise { + return this.queryViews('AND i.source_id = $1', [inventoryId]); + } + + listForBooking(bookingId: string): Promise { + return this.queryViews('AND inv.booking_id = $1', [bookingId]); + } + + async findAll( + filter: Partial< + Pick< + WarehouseFeeInvoiceView, + 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + > + >, + ): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + const add = (sql: (p: string) => string, value: unknown) => { + params.push(value); + conditions.push(sql(`$${params.length}`)); + }; + + if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); + if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); + if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); + if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); + + return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details); - return { - filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), - }; + return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { @@ -199,76 +274,65 @@ export class WarehouseInvoiceService { if (Number(invoice.paidAmount) <= 0) { throw new BadRequestException('A receipt is available only after payment is recorded.'); } - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details); - return { - filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), - }; - } - - listForInventory(inventoryId: string): Promise { - return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); - } - - listForBooking(bookingId: string): Promise { - return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); - } - - findAll(filter: Partial>): Promise { - const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); - return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); + return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); } // ── State changes ──────────────────────────────────────────────────────── - async cancel(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); - const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); - return updated as WarehouseFeeInvoice; + async cancel(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('A paid invoice cannot be cancelled.'); + } + await this.billing.cancelInvoice(id); + return this.findById(id); } - /** Record a payment against the invoice and sync status (links to existing payment flow). */ - async pay(id: string, dto: PayInvoiceDto): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); - if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); - if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); - - const paidAmount = Number(invoice.paidAmount) + dto.amount; - const total = Number(invoice.totalAmount); - const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); - const fullyPaid = paidAmount >= total; - - const payments = [ - ...(invoice.payments ?? []), - { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, - ]; - - const updated = await this.invoiceRepository.update(id, { - paidAmount: Math.round(paidAmount * 100) / 100, - balanceAmount: balance, - status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', - paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, - payments, + /** Record a payment against the invoice (delegates settlement to billing). */ + async pay(id: string, dto: PayInvoiceDto): Promise { + // Guard that this is a warehouse invoice before recording (404 otherwise). + await this.loadWarehouseInvoice(id); + await this.billing.recordPayment(id, { + amount: dto.amount, + method: dto.method ?? null, + reference: dto.reference ?? null, + metadata: + dto.driverName || dto.driverPhone + ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + : null, }); - const paidInvoice = updated as WarehouseFeeInvoice; - await this.notifyWarehouseFeePayment(paidInvoice, dto); - return paidInvoice; + const detail = await this.findById(id); + await this.notifyWarehouseFeePayment(detail, dto); + return detail; + } + + /** + * Notify on online (gateway) settlement — the domain side-effect of a warehouse + * fee being paid through billing's payment flow. The counter {@link pay} path + * notifies inline (and carries driver details from the request), so this only + * handles gateway payments: those stamp the invoice `paymentId`, whereas a + * counter settlement leaves it null. Skipping null-`paymentId` events avoids + * double-notifying a counter payment that already sent its SMS. + */ + @OnEvent('warehouse.invoice.paid') + async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { + if (!payload.paymentId) return; + const detail = await this.findById(payload.invoiceId); + await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); } // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; + async findBlockingInvoice(inventoryId: string): Promise { + const blocking = await this.queryViews( + `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, + [inventoryId, BLOCKING_STATUSES], + ); + return blocking[0] ?? null; } async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); + const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); + const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); if (blocking) { throw new BadRequestException( `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, @@ -286,12 +350,218 @@ export class WarehouseInvoiceService { } } - private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + // ── Internal: loading & projection ───────────────────────────────────────── + + /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ + private async loadWarehouseInvoice(id: string): Promise { + const invoice = await this.billing.findById(id); + if (invoice.source !== SOURCE) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + private async hasActiveInvoice(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT 1 + FROM freight.invoices + WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL + LIMIT 1`, + [SOURCE, inventoryId, ACTIVE_STATUSES], + ); + return Boolean(row); + } + + /** + * Project warehouse-source global invoices into the historical view, joined to + * their inventory item for the typed FKs. Powers every list/filter read. + */ + private async queryViews(extraWhere: string, params: unknown[]): Promise { + const rows = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", + i.source_id AS "sourceId", i.type, i.status, + i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount", + i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount", + i.balance_amount AS "balanceAmount", i.currency, i.payments, + i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt", + i.created_at AS "createdAt", i.updated_at AS "updatedAt", + inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} + ORDER BY i.created_at DESC`, + [...params, SOURCE], + ); + + return (rows as Array).map((row) => + this.buildView(row, { + bookingId: row.bookingId ?? null, + facilityId: row.facilityId ?? null, + warehouseId: row.warehouseId ?? null, + yardId: row.yardId ?? null, + zoneId: row.zoneId ?? null, + periodStart: row.periodStart ?? null, + }), + ); + } + + /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ + private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { + const status = this.toWarehouseStatus(inv.status); + return { + id: inv.id, + invoiceNumber: inv.invoiceNumber, + bookingId: ctx.bookingId, + customerId: inv.companyId ?? null, + inventoryId: inv.sourceId, + facilityId: ctx.facilityId, + warehouseId: ctx.warehouseId, + yardId: ctx.yardId, + zoneId: ctx.zoneId, + invoiceType: inv.type as WarehouseInvoiceType, + status, + subtotalAmount: Number(inv.subtotalAmount), + taxAmount: Number(inv.taxAmount), + totalAmount: Number(inv.totalAmount), + paidAmount: Number(inv.paidAmount), + balanceAmount: Number(inv.balanceAmount), + currency: inv.currency, + periodStart: ctx.periodStart, + // No standalone period column once centralized: the charge window ends at + // issuance, so `issuedAt` is the period end. + periodEnd: inv.issuedAt ?? null, + issuedAt: inv.issuedAt ?? null, + dueDate: inv.dueAt ?? null, + paidAt: inv.paidAt ?? null, + cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + payments: (inv.payments ?? []).map((p) => ({ + amount: Number(p.amount), + method: p.method ?? null, + reference: p.reference ?? null, + paidAt: p.paidAt, + })), + notes: null, + createdAt: inv.createdAt, + updatedAt: inv.updatedAt, + }; + } + + private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView { + const meta = (line.metadata ?? {}) as { + feeRuleId?: string | null; + chargeableDays?: number | null; + freeDays?: number | null; + }; + return { + feeRuleId: meta.feeRuleId ?? null, + feeType: line.chargeType as WarehouseFeeType, + description: line.description ?? '', + quantity: Number(line.quantity), + unitRate: Number(line.unitRate), + amount: Number(line.amount), + currency: line.currency, + chargeableDays: meta.chargeableDays ?? null, + freeDays: meta.freeDays ?? null, + }; + } + + private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { + switch (status) { + case Freight.InvoiceStatus.Draft: + return 'DRAFT'; + case Freight.InvoiceStatus.PartiallyPaid: + return 'PARTIALLY_PAID'; + case Freight.InvoiceStatus.Paid: + return 'PAID'; + case Freight.InvoiceStatus.Cancelled: + case Freight.InvoiceStatus.Refunded: + return 'CANCELLED'; + default: + // Issued / Pending / Overdue → an issued, still-owed invoice. + return 'ISSUED'; + } + } + + private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { + switch (status) { + case 'DRAFT': + return Freight.InvoiceStatus.Draft; + case 'PARTIALLY_PAID': + return Freight.InvoiceStatus.PartiallyPaid; + case 'PAID': + return Freight.InvoiceStatus.Paid; + case 'CANCELLED': + return Freight.InvoiceStatus.Cancelled; + default: + return Freight.InvoiceStatus.Issued; + } + } + + /** Map a warehouse fee invoice view onto the shared document model. */ + private toDocumentModel( + invoice: WarehouseFeeInvoiceDetail, + kind: 'INVOICE' | 'RECEIPT', + ): InvoiceDocumentModel { + const lastPayment = [...(invoice.payments ?? [])].pop(); + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + + return { + kind, + title: 'Warehouse Fee', + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: 'Status', value: invoice.status.replace(/_/g, ' ') }, + { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') }, + { label: 'Booking reference', value: invoice.bookingReference ?? null }, + { label: 'Customer', value: invoice.customerName ?? null }, + { label: 'Inventory reference', value: invoice.inventoryReference ?? null }, + { label: 'Inventory info', value: invoice.inventoryInfo ?? null }, + { label: 'Clearance', value: invoice.clearanceStatus ?? null }, + { label: 'Warehouse', value: invoice.warehouseName ?? null }, + { + label: 'Yard / Zone', + value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + }, + { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, + { + label: 'Payment', + value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, + }, + ], + categoryHeader: 'Fee type', + lines: invoice.items.map((item) => ({ + description: item.description ?? null, + category: item.feeType ?? null, + quantity: item.quantity ?? item.chargeableDays ?? 0, + unitRate: item.unitRate, + amount: item.amount, + currency: item.currency ?? invoice.currency, + })), + totals: [ + { label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, + { label: 'Tax', amount: Number(invoice.taxAmount) }, + { label: 'Total', amount: Number(invoice.totalAmount), grand: true }, + { label: 'Paid', amount: Number(invoice.paidAmount) }, + { label: 'Balance', amount: Number(invoice.balanceAmount) }, + ], + }; + } + + /** Warehouse-specific display details, derived from the linked inventory item. */ + private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", inv.status AS "inventoryStatus", + inv.release_date AS "releaseDate", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", CONCAT_WS( @@ -302,16 +572,10 @@ export class WarehouseInvoiceService { ) AS "inventoryInfo", wh.name AS "warehouseName", yard.name AS "yardName", - zone.name AS "zoneName", - CASE - WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED' - WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE' - ELSE 'PENDING PAYMENT' - END AS "clearanceStatus" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + zone.name AS "zoneName" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -319,14 +583,21 @@ export class WarehouseInvoiceService { ) LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) - LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id - LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id - LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id - WHERE fee.id = $1 + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id, invoice.status], + [invoice.sourceId], ); + const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const clearanceStatus = row?.releaseDate + ? 'RELEASE ISSUED' + : fullyPaid + ? 'FEE PAID - READY FOR RELEASE' + : 'PENDING PAYMENT'; + return { bookingReference: row?.bookingReference ?? null, customerName: row?.customerName ?? null, @@ -338,11 +609,33 @@ export class WarehouseInvoiceService { warehouseName: row?.warehouseName ?? null, yardName: row?.yardName ?? null, zoneName: row?.zoneName ?? null, - clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'), + clearanceStatus, }; } - private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{ + private async getInventoryContext(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [inventoryId], + ); + return { + bookingId: row?.bookingId ?? null, + facilityId: row?.facilityId ?? null, + warehouseId: row?.warehouseId ?? null, + yardId: row?.yardId ?? null, + zoneId: row?.zoneId ?? null, + periodStart: row?.periodStart ?? null, + }; + } + + // ── Notifications ────────────────────────────────────────────────────────── + private async getInvoiceNotificationContacts(inventoryId: string): Promise<{ bookingReference: string | null; customerName: string | null; customerPhone: string | null; @@ -364,10 +657,9 @@ export class WarehouseInvoiceService { COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -393,9 +685,9 @@ export class WarehouseInvoiceService { ) latest_first_mile ON true LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id - WHERE fee.id = $1 + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id], + [inventoryId], ); return { @@ -419,8 +711,8 @@ export class WarehouseInvoiceService { } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const cargo = contacts.containerNumber || contacts.cargoDescription; @@ -433,8 +725,8 @@ export class WarehouseInvoiceService { await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); + private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); const customerName = contacts.customerName?.trim() || 'Customer'; const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; const statusText = @@ -460,131 +752,4 @@ export class WarehouseInvoiceService { await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); } - - private buildInvoiceDocumentHtml( - invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, - kind: 'INVOICE' | 'RECEIPT', - details: InvoiceDocumentDetails, - ): string { - const esc = (value: unknown) => - String(value ?? '-') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - const money = (amount: unknown, currency = invoice.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); - const items = invoice.items as Array<{ - id?: string; - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; - const lastPayment = [...(invoice.payments ?? [])].pop(); - const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR'; - - return ` - - - - Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} - - - -
-
-
-
Ethio-Djibouti Railway S.C.
-

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

-
-
- Document no. - ${esc(invoice.invoiceNumber)} - Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} -
-
-
${esc(sealText)}
-
-
Status${esc(invoice.status.replace(/_/g, ' '))}
-
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking reference${esc(details.bookingReference)}
-
Customer${esc(details.customerName)}
-
Inventory reference${esc(details.inventoryReference)}
-
Inventory info${esc(details.inventoryInfo)}
-
Clearance${esc(details.clearanceStatus)}
-
Warehouse${esc(details.warehouseName)}
-
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
-
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
-
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
-
- - - - - - - - - - - - ${items - .map( - (item) => ` - - - - - - `, - ) - .join('')} - -
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
-
-
Subtotal${esc(money(invoice.subtotalAmount))}
-
Tax${esc(money(invoice.taxAmount))}
-
Total${esc(money(invoice.totalAmount))}
-
Paid${esc(money(invoice.paidAmount))}
-
Balance${esc(money(invoice.balanceAmount))}
-
- -
- -`; - } - - private safeFilename(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts new file mode 100644 index 000000000..e201241ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -0,0 +1,88 @@ +/** + * Public shapes for warehouse fee invoices. + * + * Warehouse fee invoices are no longer a standalone table — they are global + * `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the + * central {@link BillingService}. These types preserve the warehouse-facing API + * contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines + + * inventory context) back into the historical `WarehouseFeeInvoice` JSON so the + * portal/backoffice stay untouched. + */ + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** A billed warehouse fee line, projected from a global `InvoiceLine`. */ +export interface WarehouseInvoiceItemView { + feeRuleId: string | null; + feeType: WarehouseFeeType; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + chargeableDays: number | null; + freeDays: number | null; +} + +/** + * The warehouse-facing invoice header — same field set the old + * `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The + * typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the + * charge `period` are derived from the linked inventory item; `customerId` is the + * billed company; `invoiceType` is the invoice `type`. + */ +export interface WarehouseFeeInvoiceView { + id: string; + invoiceNumber: string; + bookingId: string | null; + customerId: string | null; + inventoryId: string; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + invoiceType: WarehouseInvoiceType; + status: WarehouseInvoiceStatus; + subtotalAmount: number; + taxAmount: number; + totalAmount: number; + paidAmount: number; + balanceAmount: number; + currency: string; + periodStart: Date | null; + periodEnd: Date | null; + issuedAt: Date | null; + dueDate: Date | null; + paidAt: Date | null; + cancelledAt: Date | null; + payments: WarehouseInvoicePayment[]; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index a77a46c29..f8c0dd355 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,101 +1,23 @@ -import { existsSync } from 'fs'; +import { Injectable } from '@nestjs/common'; -import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; const MIN_VALID_PDF_BYTES = 2_000; -const RELEASE_DOCUMENT_PRINT_STYLES = ` -`; - @Injectable() export class WarehouseReleaseDocumentService { - private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + constructor(private readonly pdf: PdfRenderService) {} - async htmlToPdfBuffer(html: string): Promise { - const preparedHtml = this.injectPdfPrintStyles(html); - const executablePath = this.resolveExecutablePath(); - - try { - const puppeteer = await import('puppeteer'); - const launchOptions: import('puppeteer').LaunchOptions = { - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - ...(executablePath ? { executablePath } : {}), - }; - - const browser = await puppeteer.default.launch(launchOptions); - try { - const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); - await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); - await page.emulateMediaType('print'); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const pdf = await page.pdf({ - format: 'A4', - printBackground: true, - margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, - }); - - const buffer = Buffer.from(pdf); - if (!this.isValidPdf(buffer)) { - throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); - } - this.logger.log( - `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, - ); - return buffer; - } finally { - await browser.close(); - } - } catch (error) { - this.logger.error( - `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, - ); - const fallback = this.htmlToBasicPdfBuffer(preparedHtml); - if (this.isValidPdf(fallback)) { - this.logger.warn( - `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, - ); - return fallback; - } - throw new InternalServerErrorException( - 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', - ); - } - } - - private injectPdfPrintStyles(html: string): string { - if (html.includes('warehouse-release-document-print-fix')) return html; - if (html.includes('')) { - return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); - } - return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; - } - - private resolveExecutablePath(): string | undefined { - const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); - if (fromEnv && existsSync(fromEnv)) return fromEnv; - - const candidates = [ - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/bin/google-chrome-stable', - '/usr/bin/google-chrome', - ]; - return candidates.find((path) => existsSync(path)); - } - - private isValidPdf(buffer: Buffer): boolean { - return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + /** + * Render the gate-clearance release document to PDF via the shared renderer, + * falling back to the release-specific hand-built layout when Chromium is + * unavailable. + */ + htmlToPdfBuffer(html: string): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label: 'Warehouse release', + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml), + }); } private htmlToBasicPdfBuffer(html: string): Buffer { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d880a3554..b871d2a36 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; @@ -10,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseInvoiceController } from './warehouse-invoice.controller'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseRulesController } from './warehouse-rules.controller'; @@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, - WarehouseFeeInvoice, - WarehouseFeeInvoiceItem, ]), + BillingModule, + DocumentsModule, FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), @@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionRepository, WarehouseAllocationRuleRepository, WarehouseFeeRuleRepository, - WarehouseFeeInvoiceRepository, - WarehouseFeeInvoiceItemRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index 467c474ee..52598cb95 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, "module": "node16", "moduleResolution": "node16" }, diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 93caae0a4..48b9bc0a9 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183", + "dev": "vite --port 5183 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9f866d756..960531fda 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5173", + "dev": "vite --port 5173 --clearScreen false", "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "lint": "eslint src", diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dbfcf655d..171a294fb 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -131,7 +131,11 @@ export enum PaymentStatus { export enum InvoiceStatus { Draft = "DRAFT", + /** Issued and awaiting payment (alias of PENDING for fee invoices). */ + Issued = "ISSUED", Pending = "PENDING", + /** Some, but not all, of the balance has been settled. */ + PartiallyPaid = "PARTIALLY_PAID", Paid = "PAID", Overdue = "OVERDUE", Cancelled = "CANCELLED",