From 5bad245ce8f871c1c5b3048f5a8b0610479494f4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 29 Jun 2026 07:11:52 +0000 Subject: [PATCH] feat: setup billing services --- apps/edr-freight-api/src/app.module.ts | 3 +- .../src/modules/billing/billing.controller.ts | 14 +- .../modules/billing/billing.service.spec.ts | 190 +++--- .../src/modules/billing/billing.service.ts | 542 ++++++++++-------- packages/types/src/freight/index.ts | 8 + 5 files changed, 419 insertions(+), 338 deletions(-) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a598da8e4..fff3dc8f1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; +import { EventEmitterModule } from "@nestjs/event-emitter"; import { DataSource, DataSourceOptions } from "typeorm"; import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; @@ -76,7 +77,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], }), ScheduleModule.forRoot(), - // EventEmitterModule.forRoot(), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => 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 e3778e11b..e7ac41704 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -8,7 +8,7 @@ import { BillingService } from "./billing.service"; @Controller("billing") @FreightAdmin() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor(private readonly billingService: BillingService) { } @Get("invoices") @ApiOperation({ summary: "List all invoices" }) @@ -16,21 +16,9 @@ export class BillingController { return this.billingService.findAll(); } - @Get("invoices/booking/:bookingId") - @ApiOperation({ summary: "List invoices for a booking" }) - 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 index 4e2d27bb8..62b3cebaa 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 @@ -1,17 +1,16 @@ 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. + * `generateInvoice` / `markInvoiceAsPaid` 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 }; + const row = { id: data.id ?? "gen-1", ...data }; if (data.invoiceId) savedLines.push(row); return Promise.resolve(row); }, @@ -21,127 +20,176 @@ function makeManager(savedLines: unknown[]) { }; } -function makeBooking(overrides: Record = {}) { +function makeEvents() { + return { emit: jest.fn() }; +} + +function generateInput(overrides: Record = {}) { return { - id: "booking-1", - reference: "BK-001", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "prepaid", 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" }, - ], - }, + currency: "ETB", + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000 }, + { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500 }, + ], ...overrides, }; } -describe("BillingService.generateForBooking", () => { - let invoices: { findAll: jest.Mock; findById: jest.Mock }; - let invoiceLines: { findAll: jest.Mock }; +describe("BillingService.generateInvoice", () => { let savedLines: unknown[]; let manager: ReturnType; - let bookingRow: Record | null; - let dataSource: { - getRepository: jest.Mock; - transaction: jest.Mock; - manager: unknown; - }; + let events: ReturnType; + let dataSource: { 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(); + events = makeEvents(); 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); + service = new BillingService(dataSource as never, {} as never, {} as never, events as never); }); - it("creates a PENDING invoice with one line per pricing line item", async () => { - const invoice = (await service.generateForBooking("booking-1")) as Invoice; + it("creates a PENDING invoice with one line per input line", async () => { + const invoice = await service.generateInvoice(generateInput()); - 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.issuedAt).toBeInstanceOf(Date); 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; - + it("sums line amounts when no explicit totalAmount is given", async () => { + const invoice = await service.generateInvoice( + generateInput({ totalAmount: undefined }), + ); expect(invoice.totalAmount).toBe(1500); - expect(savedLines).toHaveLength(1); - expect((savedLines[0] as { chargeType: string }).chargeType).toBe("FREIGHT"); + }); + + it("leaves issuedAt null for a DRAFT invoice", async () => { + const invoice = await service.generateInvoice( + generateInput({ status: Freight.InvoiceStatus.Draft }), + ); + expect(invoice.status).toBe(Freight.InvoiceStatus.Draft); + expect(invoice.issuedAt).toBeNull(); + }); + + it("enlists in a caller's transaction when a manager is passed", async () => { + await service.generateInvoice(generateInput(), manager as never); + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect(savedLines).toHaveLength(2); }); }); -describe("BillingService.markBookingInvoicePaid", () => { - it("marks the open booking invoice PAID and links the payment", async () => { - const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending }; +describe("BillingService.markInvoiceAsPaid", () => { + it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + }; 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); + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + ); - await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never); + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }), + ); }); - it("is a no-op when the booking has no open invoice", async () => { + it("is a no-op (no event) when the invoice is already paid", async () => { + const paid = { id: "inv-1", status: Freight.InvoiceStatus.Paid, source: "booking" }; + const mg = { + findOne: jest.fn().mockResolvedValue(paid), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); + +describe("BillingService.settlePayable", () => { + it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + expect(events.emit).toHaveBeenCalledWith("booking.invoice.paid", expect.anything()); + }); + + it("is a no-op (returns null) when the source 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); + const events = makeEvents(); + const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never); - await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never); + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + expect(settled).toBeNull(); expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).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 fd95a12f6..ff41eb4e6 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,57 +1,74 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; 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[] = [ +/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ +const OPEN_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 { +/** A single line to bill on a generated invoice. */ +export interface InvoiceLineInput { chargeType: string; - description: string; - quantity: number; - unitRate: number; - amount: number; + description?: string; + /** Units this line bills for; defaults to 1. */ + quantity?: number; + /** Price per unit; defaults to 0. */ + unitRate?: number; + /** Line total; defaults to `quantity * unitRate`. */ + amount?: number; + currency?: string; + metadata?: Record | null; +} + +/** Everything needed to generate an invoice for any source. */ +export interface GenerateInvoiceInput { + /** Originating subsystem; namespaces events (`${source}.invoice.`). */ + source: Freight.InvoiceSource; + /** Identifier of the source record (e.g. booking id). */ + sourceId: string; + /** What the invoice is for (e.g. "prepaid", "credit"). */ + type: string; + companyId: string; + companyProfileId: string; + lines: InvoiceLineInput[]; + currency?: string; + /** Explicit total; defaults to the sum of line amounts. */ + totalAmount?: number; + /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ + dueAt?: Date; + dueInDays?: number; + /** + * Initial status. DRAFT leaves `issuedAt` null; any issued status + * (default PENDING) stamps `issuedAt`. + */ + status?: Freight.InvoiceStatus; +} + +/** Payload broadcast on `${source}.invoice.`. */ +export interface InvoiceEventPayload { + invoiceId: string; + invoiceNumber: string; + source: Freight.InvoiceSource; + sourceId: string; + type: string; + companyId: string; + companyProfileId: string; + totalAmount: number; currency: string; - metadata?: Record; + status: Freight.InvoiceStatus; + paymentId?: string | null; } @Injectable() @@ -62,7 +79,8 @@ export class BillingService { private readonly dataSource: DataSource, private readonly invoices: InvoiceRepository, private readonly invoiceLines: InvoiceLineRepository, - ) {} + private readonly events: EventEmitter2, + ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -71,14 +89,6 @@ export class BillingService { return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } - /** List invoices for a given booking. */ - findByBooking(bookingId: string): Promise { - 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); @@ -92,126 +102,6 @@ export class BillingService { // ── 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(); @@ -226,108 +116,254 @@ export class BillingService { return `${prefix}${String(next).padStart(5, "0")}`; } - // ── Payment reconciliation ─────────────────────────────────────────────────── - /** - * The invoice a gateway payment should settle for a booking, or null if none. + * Generate an invoice for any source (booking, demurrage, manual, …). * - * 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. + * Persists the header plus its lines in one transaction and assigns the next + * sequential `invoice_number`. The total defaults to the sum of line amounts + * unless `totalAmount` is given. Issued invoices (default PENDING) stamp + * `issuedAt`; pass `status: DRAFT` to leave it unissued. + * + * Pass `manager` to enlist in a caller's transaction (e.g. when generating an + * invoice as part of a larger booking flow). */ - 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, + async generateInvoice( + input: GenerateInvoiceInput, 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 }, - ); + ): Promise { + const run = (mg: EntityManager) => this.createInvoice(input, mg); + return manager ? run(manager) : this.dataSource.transaction(run); } - /** - * 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; + private async createInvoice( + input: GenerateInvoiceInput, + mg: EntityManager, + ): Promise { + const currency = input.currency ?? "ETB"; + const status = input.status ?? Freight.InvoiceStatus.Pending; + const issued = status !== Freight.InvoiceStatus.Draft; - await mg.update( - Invoice, - { id: invoice.id }, - { status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined }, + const lines = input.lines.map((l) => { + const quantity = l.quantity ?? 1; + const unitRate = l.unitRate ?? 0; + return { + chargeType: l.chargeType, + description: l.description, + quantity, + unitRate, + amount: l.amount ?? quantity * unitRate, + currency: l.currency ?? currency, + metadata: l.metadata ?? null, + }; + }); + + const totalAmount = + input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + + const dueAt = + input.dueAt ?? + new Date( + Date.now() + + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + ); + + const invoiceNumber = await this.nextInvoiceNumber(mg); + + const invoice = await mg.save( + mg.create(Invoice, { + invoiceNumber, + source: input.source, + sourceId: input.sourceId, + type: input.type, + companyId: input.companyId, + companyProfileId: input.companyProfileId, + totalAmount, + currency, + status, + issuedAt: issued ? new Date() : null, + dueAt, + }), ); + + const savedLines = await Promise.all( + lines.map((l) => + mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })), + ), + ); + + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`, + ); + + return { ...invoice, lines: savedLines }; } + // ── State transitions ──────────────────────────────────────────────────────── + /** - * 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). + * Mark an invoice paid and link the gateway payment, then emit + * `${source}.invoice.paid`. Full-payment only — no partial settlement. + * No-op when the invoice is already paid. Pass `manager` to enlist in a + * caller's transaction. */ - async settleBookingInvoice( - bookingId: string, + async markInvoiceAsPaid( + invoiceId: string, paymentId: string | null = null, manager?: EntityManager, - ): Promise { - await this.generateForBooking(bookingId); - await this.markBookingInvoicePaid(bookingId, paymentId, manager); + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Paid, + "paid", + { paymentId: paymentId ?? undefined }, + manager, + ); + } + + /** + * Mark an invoice refunded and emit `${source}.invoice.refunded`. + * No-op when already refunded. + */ + async markInvoiceAsRefunded( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Refunded, + "refunded", + {}, + manager, + ); + } + + /** + * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. + * No-op when already cancelled. + */ + async cancelInvoice( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Cancelled, + "cancelled", + {}, + manager, + ); + } + + /** + * Load the invoice, apply the new status (+ extra columns), then emit + * `${source}.invoice.`. No-op (returns the invoice) when it is already + * in the target status. Throws when the invoice does not exist. + * + * Note: the event fires in-process synchronously. When a `manager` from an + * outer transaction is passed, listeners run before that transaction commits. + */ + private async transition( + invoiceId: string, + status: Freight.InvoiceStatus, + event: string, + extra: { paymentId?: string }, + manager?: EntityManager, + ): Promise { + 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 === status) return invoice; + + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + + const updated = { ...invoice, ...extra, status } as Invoice; + this.emitInvoiceEvent(event, updated); + return updated; + } + + /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ + private emitInvoiceEvent(event: string, invoice: Invoice): void { + const payload: InvoiceEventPayload = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source as Freight.InvoiceSource, + sourceId: invoice.sourceId, + type: invoice.type, + companyId: invoice.companyId, + companyProfileId: invoice.companyProfileId, + totalAmount: invoice.totalAmount, + currency: invoice.currency, + status: invoice.status, + paymentId: invoice.paymentId ?? null, + }; + this.events.emit(`${invoice.source}.invoice.${event}`, payload); + } + + // ── Payment reconciliation (by source) ─────────────────────────────────────── + + /** + * The invoice a gateway payment should settle for a source record, or null if + * none. This is the billing document of record for "what is owed" — callers + * (e.g. `payment.service.initiatePayment`) should charge `invoice.totalAmount` + * against it rather than recomputing from the source's own total, so + * discounts/penalties/adjustments carried on the invoice are honored. Returns + * the most recent open (unpaid, non-cancelled) invoice. + */ + findPayable( + source: Freight.InvoiceSource, + sourceId: string, + ): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + } + + /** + * Settle a source's open invoice as paid and link the gateway payment, then + * emit `${source}.invoice.paid`. Resolves the open invoice via {@link findPayable} + * then delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial + * settlement. No-op (returns null) when the source has no open invoice. + * + * Pass the caller's transaction `manager` (e.g. from + * `payment.service.finalizePaymentSuccess`) to enlist in its DB transaction. + */ + async settlePayable( + source: Freight.InvoiceSource, + sourceId: string, + paymentId: string | null, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId, mg); + } + + /** + * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. + * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. + * No-op (returns null) when the source has no paid invoice. + * + * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) + * to enlist in its DB transaction. + */ + async refundPayable( + source: Freight.InvoiceSource, + sourceId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsRefunded(invoice.id, mg); } } - -/** Round to 2 decimal places without float drift. */ -function round2(n: number): number { - return Math.round(Number(n) * 100) / 100; -} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 040848021..374eee28a 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -137,6 +137,14 @@ export enum InvoiceStatus { Refunded = "REFUNDED", } +/** Originating subsystem an invoice bills for; namespaces invoice events. */ +export enum InvoiceSource { + Booking = "booking", + Contract = "contract", + Warehouse = "warehouse", + Demurrage = "demurrage", +} + export enum SchedulingStatus { NotScheduled = "NOT_SCHEDULED", Holding = "HOLDING",