From 55c42058d0fdcd9609a9cccbddcc4850a270b91b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 27 Jun 2026 08:42:18 +0000 Subject: [PATCH] chore: updating billing logic --- .../src/modules/billing/billing.controller.ts | 14 +- .../modules/billing/billing.service.spec.ts | 147 ++++++++ .../src/modules/billing/billing.service.ts | 323 +++++++++++++++++- 3 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/billing.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5a801cf73..e3778e11b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; +import { Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FreightAdmin } from "../../common/booking-guards"; @@ -21,4 +21,16 @@ export class BillingController { findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { return this.billingService.findByBooking(bookingId); } + + @Get("invoices/:id") + @ApiOperation({ summary: "Get an invoice with its line items" }) + findById(@Param("id", ParseUUIDPipe) id: string) { + return this.billingService.findById(id); + } + + @Post("invoices/generate/:bookingId") + @ApiOperation({ summary: "Generate (or return the existing) invoice for a booking" }) + generate(@Param("bookingId", ParseUUIDPipe) bookingId: string) { + return this.billingService.generateForBooking(bookingId); + } } diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts new file mode 100644 index 000000000..4e2d27bb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -0,0 +1,147 @@ +import { Freight } from "@edr/types"; + +import { BillingService } from "./billing.service"; +import type { Invoice } from "./entities/invoice.entity"; + +/** + * Minimal in-memory EntityManager stand-in covering the methods + * `generateForBooking` / `markBookingInvoicePaid` call on the transaction manager. + */ +function makeManager(savedLines: unknown[]) { + return { + create: (_entity: unknown, data: Record) => data, + save: (data: Record) => { + const row = { id: data.id ?? `gen-${Math.round(0)}`, ...data }; + if (data.invoiceId) savedLines.push(row); + return Promise.resolve(row); + }, + query: () => Promise.resolve([{ seq: 0 }]), + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue(null), + }; +} + +function makeBooking(overrides: Record = {}) { + return { + id: "booking-1", + reference: "BK-001", + companyId: "company-1", + companyProfileId: "profile-1", + paymentCurrency: "ETB", + totalAmount: 1500, + pricingBreakdown: { + currency: "ETB", + totalAmount: 1500, + lineItems: [ + { code: "RAIL_FREIGHT", description: "Rail freight", amount: 1000, unitAmount: 500, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" }, + { code: "HAZARD_SURCHARGE", description: "Hazard surcharge", amount: 500, unitAmount: 250, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" }, + ], + }, + ...overrides, + }; +} + +describe("BillingService.generateForBooking", () => { + let invoices: { findAll: jest.Mock; findById: jest.Mock }; + let invoiceLines: { findAll: jest.Mock }; + let savedLines: unknown[]; + let manager: ReturnType; + let bookingRow: Record | null; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + manager: unknown; + }; + let service: BillingService; + + beforeEach(() => { + savedLines = []; + manager = makeManager(savedLines); + invoices = { findAll: jest.fn().mockResolvedValue([]), findById: jest.fn() }; + invoiceLines = { findAll: jest.fn().mockResolvedValue([]) }; + bookingRow = makeBooking(); + dataSource = { + getRepository: jest.fn().mockReturnValue({ + findOne: jest.fn().mockImplementation(() => Promise.resolve(bookingRow)), + }), + transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + service = new BillingService(dataSource as never, invoices as never, invoiceLines as never); + }); + + it("creates a PENDING invoice with one line per pricing line item", async () => { + const invoice = (await service.generateForBooking("booking-1")) as Invoice; + + expect(invoice).toBeTruthy(); + expect(invoice.status).toBe(Freight.InvoiceStatus.Pending); + expect(invoice.companyId).toBe("company-1"); + expect(invoice.companyProfileId).toBe("profile-1"); + expect(invoice.source).toBe("booking"); + expect(invoice.sourceId).toBe("booking-1"); + expect(invoice.totalAmount).toBe(1500); + expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(savedLines).toHaveLength(2); + }); + + it("returns the existing active invoice instead of creating a duplicate", async () => { + const existing = { id: "inv-existing", status: Freight.InvoiceStatus.Pending } as Invoice; + invoices.findAll.mockResolvedValueOnce([existing]); + + const invoice = await service.generateForBooking("booking-1"); + + expect(invoice).toBe(existing); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("skips generation (returns null) when the booking has no company to bill", async () => { + bookingRow = makeBooking({ companyId: null, companyProfileId: null }); + + const invoice = await service.generateForBooking("booking-1"); + + expect(invoice).toBeNull(); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("falls back to a single freight line when no pricing breakdown exists", async () => { + bookingRow = makeBooking({ pricingBreakdown: null }); + + const invoice = (await service.generateForBooking("booking-1")) as Invoice; + + expect(invoice.totalAmount).toBe(1500); + expect(savedLines).toHaveLength(1); + expect((savedLines[0] as { chargeType: string }).chargeType).toBe("FREIGHT"); + }); +}); + +describe("BillingService.markBookingInvoicePaid", () => { + it("marks the open booking invoice PAID and links the payment", async () => { + const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { manager: mg } as never; + const service = new BillingService(dataSource, {} as never, {} as never); + + await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never); + + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + }); + + it("is a no-op when the booking has no open invoice", async () => { + const mg = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService({ manager: mg } as never, {} as never, {} as never); + + await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39eae6ef5..fd95a12f6 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,26 +1,333 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { Freight } from "@edr/types"; +import { DataSource, EntityManager, In } from "typeorm"; +import { Booking } from "../bookings/entities/booking.entity"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; + +/** Source tag stamped on booking invoices (`source` column). */ +const BOOKING_SOURCE = "booking"; + +/** Default invoice payment-term window, in days, used to compute `dueAt`. */ +const DEFAULT_DUE_DAYS = 14; + +/** + * Statuses an invoice can hold while it still represents the live bill for a + * booking. A second `generateForBooking` call returns the existing one of these + * instead of creating a duplicate (idempotency / dedup guard). + */ +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.Paid, + Freight.InvoiceStatus.Overdue, +]; + +/** Shape of a single line inside `booking.pricingBreakdown.lineItems`. */ +interface StoredPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + unit: string; + quantity: number; + currency: string; +} + +interface StoredPricingBreakdown { + lineItems?: StoredPriceLine[]; + totalAmount?: number; + currency?: string; +} + +/** A fully-resolved invoice line ready to persist. */ +interface BuiltLine { + chargeType: string; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + metadata?: Record; +} @Injectable() export class BillingService { + private readonly logger = new Logger(BillingService.name); + constructor( - @InjectRepository(Invoice) - private readonly invoicesRepository: Repository, + private readonly dataSource: DataSource, + private readonly invoices: InvoiceRepository, + private readonly invoiceLines: InvoiceLineRepository, ) {} + // ── Reads ────────────────────────────────────────────────────────────────── + /** List every invoice (most recent first). */ findAll(): Promise { - return this.invoicesRepository.find({ order: { issuedAt: "DESC" } }); + return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } /** List invoices for a given booking. */ findByBooking(bookingId: string): Promise { - return this.invoicesRepository.find({ - where: { bookingId }, + return this.invoices.findAll({ + where: { source: BOOKING_SOURCE, sourceId: bookingId }, order: { issuedAt: "DESC" }, }); } + + /** Invoice header plus its line items. */ + async findById(id: string): Promise { + const invoice = await this.invoices.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const lines = await this.invoiceLines.findAll({ + where: { invoiceId: id }, + order: { createdAt: "ASC" }, + }); + return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + } + + // ── Generation ─────────────────────────────────────────────────────────────── + + /** + * Generate the booking's invoice from its snapshotted pricing breakdown. + * + * Called when a booking reaches a billable state (full contract execution / + * contract activation). Idempotent: a booking that already has an active + * invoice gets that invoice back instead of a duplicate. + * + * Returns `null` (and logs) when the booking is not billable yet — no pricing + * breakdown, or no customer company/profile to bill (e.g. government/legacy + * bookings whose `company_id` is null, which the `invoices` FK requires). + */ + async generateForBooking(bookingId: string): Promise { + const existing = await this.invoices.findAll({ + where: { source: BOOKING_SOURCE, sourceId: bookingId }, + }); + const active = existing.find((inv) => ACTIVE_STATUSES.includes(inv.status)); + if (active) return active; + + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + if (!booking.companyId) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${bookingId}): no company to bill.`, + ); + return null; + } + const companyId = booking.companyId; + const companyProfileId = booking.companyProfileId ?? null; + + const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; + const lines = this.buildLines(breakdown, booking.totalAmount, currency); + const subtotal = round2(lines.reduce((sum, l) => sum + l.amount, 0)); + + // Honor a staff price override: bill the adjusted total, recording the delta + // as an ADJUSTMENT line so the signed lines still sum to the invoice total. + const adjusted = booking.adjustedTotalAmount; + let total = subtotal; + if (adjusted != null && Number.isFinite(Number(adjusted))) { + const delta = round2(Number(adjusted) - subtotal); + if (delta !== 0) { + lines.push({ + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", + quantity: 1, + unitRate: delta, + amount: delta, + currency, + }); + } + total = round2(Number(adjusted)); + } + + const issuedAt = new Date(); + const dueAt = new Date(issuedAt); + dueAt.setDate(dueAt.getDate() + DEFAULT_DUE_DAYS); + + return this.dataSource.transaction(async (mg) => { + const invoice = await mg.save( + mg.create(Invoice, { + invoiceNumber: await this.nextInvoiceNumber(mg), + companyId, + companyProfileId, + totalAmount: total, + currency, + status: Freight.InvoiceStatus.Pending, + source: BOOKING_SOURCE, + sourceId: bookingId, + type: "PREPAID", + issuedAt, + dueAt, + }), + ); + + for (const line of lines) { + await mg.save(mg.create(InvoiceLine, { invoiceId: invoice.id, ...line })); + } + + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} for booking ${booking.reference} (${total} ${currency}).`, + ); + return invoice; + }); + } + + /** Map the stored pricing line items to invoice lines (one breakdown line → one invoice line). */ + private buildLines( + breakdown: StoredPricingBreakdown, + fallbackTotal: number, + currency: string, + ): BuiltLine[] { + const items = breakdown.lineItems ?? []; + if (items.length === 0) { + // No itemized breakdown — bill a single line for the booking total. + return [ + { + chargeType: "FREIGHT", + description: "Freight charge", + quantity: 1, + unitRate: round2(fallbackTotal), + amount: round2(fallbackTotal), + currency, + }, + ]; + } + + return items.map((item) => ({ + chargeType: item.code, + description: item.description, + quantity: item.quantity, + unitRate: round2(item.unitAmount), + amount: round2(item.amount), + currency: item.currency ?? currency, + metadata: { unit: item.unit }, + })); + } + + /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ + private async nextInvoiceNumber(mg: EntityManager): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `FRT-${ymd}-`; + const [row] = await mg.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; + } + + // ── Payment reconciliation ─────────────────────────────────────────────────── + + /** + * The invoice a gateway payment should settle for a booking, or null if none. + * + * This is the billing document of record for "what is owed" — callers (e.g. + * `payment.service.initiatePayment`) should charge `invoice.totalAmount` against + * it rather than recomputing from `booking.totalAmount`, so discounts/penalties/ + * adjustments carried on the invoice are honored. Returns the most recent open + * (unpaid, non-cancelled) invoice. + */ + findPayableForBooking(bookingId: string): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source: BOOKING_SOURCE, + sourceId: bookingId, + status: In([ + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Overdue, + ]), + }, + order: { issuedAt: "DESC" }, + }); + } + + /** + * Mark a booking's paid invoice as refunded. Called from `payment.service.refund` + * inside its DB transaction so the invoice tracks the booking/payment reversal. + * No-op when the booking has no paid invoice. + */ + async markBookingInvoiceRefunded( + bookingId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source: BOOKING_SOURCE, + sourceId: bookingId, + status: Freight.InvoiceStatus.Paid, + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + + await mg.update( + Invoice, + { id: invoice.id }, + { status: Freight.InvoiceStatus.Refunded }, + ); + } + + /** + * Mark a booking's open invoice as paid and link the gateway payment. + * + * Called from `payment.service.finalizePaymentSuccess()` inside its existing DB + * transaction (pass the transaction's `EntityManager`). Full-payment only — no + * partial settlement in this phase. No-op when the booking has no open invoice. + */ + async markBookingInvoicePaid( + bookingId: string, + paymentId: string | null, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source: BOOKING_SOURCE, + sourceId: bookingId, + status: In([Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Overdue]), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + + await mg.update( + Invoice, + { id: invoice.id }, + { status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined }, + ); + } + + /** + * Single settlement chokepoint for any path that marks a booking PAID: ensure + * the booking has an invoice (idempotent generate), then mark it paid. Reused by + * the gateway flow and offline/manual "mark paid" so invoicing holds everywhere. + * + * No-op for bookings that have no invoice and cannot get one (e.g. government + * bookings with no company to bill — `generateForBooking` returns null). + */ + async settleBookingInvoice( + bookingId: string, + paymentId: string | null = null, + manager?: EntityManager, + ): Promise { + await this.generateForBooking(bookingId); + await this.markBookingInvoicePaid(bookingId, paymentId, manager); + } +} + +/** Round to 2 decimal places without float drift. */ +function round2(n: number): number { + return Math.round(Number(n) * 100) / 100; }