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 { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto } from "../payment/payments.dto"; 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 { method?: string; platform?: "web" | "mobile"; payerAccount?: string; returnUrl?: string; 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.Issued, Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; /** A single line to bill on a generated invoice. */ export interface InvoiceLineInput { chargeType: string; 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 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; 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; status: Freight.InvoiceStatus; paymentId?: string | null; } @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); constructor( private readonly dataSource: DataSource, private readonly invoices: InvoiceRepository, private readonly invoiceLines: InvoiceLineRepository, private readonly events: EventEmitter2, @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── /** List every invoice (most recent first). */ findAll(): Promise { return this.invoices.findAll({ 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[] }; } // ── 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). */ async resolveCompanyId(userId: string): Promise { try { const { company } = await this.companies.getCompanyInfoByUserId(userId); return company?.id ?? null; } catch { return null; } } /** * Every invoice billed to a company, newest first, with billing relations. * Optionally narrow to a single source record (e.g. a booking's invoices) via * `{ source, sourceId }`. */ findByCompany( companyId: string, filter: { source?: string; sourceId?: string } = {}, ): Promise { return this.invoices.findAll({ where: { companyId, ...(filter.source ? { source: filter.source } : {}), ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), }, relations: { company: true, companyProfile: true }, order: { createdAt: "DESC" }, }); } /** Invoices for the signed-in customer; empty when they have no company. */ async findForUser( userId: string, filter: { source?: string; sourceId?: string } = {}, ): Promise { const companyId = await this.resolveCompanyId(userId); return companyId ? this.findByCompany(companyId, filter) : []; } /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ async findByIdForUser( id: string, userId: string, ): Promise { const companyId = await this.resolveCompanyId(userId); const invoice = await this.findById(id); if (!companyId || invoice.companyId !== companyId) { throw new NotFoundException(`Invoice ${id} not found`); } return invoice; } /** * Initiate gateway payment for one of the customer's own invoices. Verifies * ownership, then charges the invoice directly by ID (see {@link payInvoice}). */ async payInvoiceForUser( id: string, userId: string, opts: PayInvoiceOptions = {}, ): Promise { await this.findByIdForUser(id, userId); return this.payInvoice(id, opts); } /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ async documentForUser( id: string, userId: string, ): Promise<{ filename: string; buffer: Buffer }> { await this.findByIdForUser(id, userId); return this.document(id); } /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ async receiptForUser( id: string, userId: string, ): Promise<{ filename: string; buffer: Buffer }> { await this.findByIdForUser(id, userId); return this.receipt(id); } // ── Generation ─────────────────────────────────────────────────────────────── /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "INV", }); } /** * Generate an invoice for any source (booking, demurrage, manual, …). * * 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). */ async generateInvoice( input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } 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; 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 subtotalAmount = input.subtotalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); const taxAmount = input.taxAmount ?? 0; const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); 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, subtotalAmount: round2(subtotalAmount), taxAmount: round2(taxAmount), totalAmount: round2(totalAmount), paidAmount: 0, balanceAmount: round2(totalAmount), payments: [], 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 ──────────────────────────────────────────────────────── /** * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, * 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 markInvoiceAsPaid( invoiceId: string, paymentId: string | null = null, 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 === Freight.InvoiceStatus.Paid) return invoice; await mg.update(Invoice, { id: invoiceId }, { status: Freight.InvoiceStatus.Paid, paymentId, paidAt: invoice.paidAt ?? new Date(), paidAmount: invoice.totalAmount, balanceAmount: 0, } as never); const updated = { ...invoice, status: Freight.InvoiceStatus.Paid, paymentId, paidAt: invoice.paidAt ?? new Date(), paidAmount: invoice.totalAmount, balanceAmount: 0, } as Invoice; this.emitInvoiceEvent("paid", updated); return updated; } /** * 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. */ 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. {@link payInvoice}) charge `invoice.totalAmount` against it rather than * recomputing from the source's own total, so discounts/penalties/adjustments * carried on the invoice are honored. * * Pass `type` to select a specific invoice when a source carries several (e.g. * a booking's up-front vs final charge); omit it to settle whichever single * invoice is currently open. Returns the most recent matching open (unpaid, * non-cancelled) invoice. */ findPayable( source: Freight.InvoiceSource, sourceId: string, type?: string, ): Promise { return this.dataSource.getRepository(Invoice).findOne({ where: { source, sourceId, status: In(OPEN_STATUSES), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); } /** * Pass `type` to select a specific invoice when a source carries several (e.g. * a booking's up-front vs final charge); omit it to settle whichever single * invoice is currently open. Returns the most recent matching open (unpaid, * non-cancelled) invoice. */ findInvoice( source: Freight.InvoiceSource, sourceId: string, type?: string, ): Promise { return this.dataSource.getRepository(Invoice).findOne({ where: { source, sourceId, ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); } /** * Expire a source's currently-open invoice (its pay window closed before * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice * and transitions it to EXPIRED — a terminal, non-payable status (kept out of * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice * (already paid/cancelled/expired). * * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in * the batch engine) to enlist in its DB transaction. */ async expirePayable( source: Freight.InvoiceSource, sourceId: string, type?: string, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { source, sourceId, status: In(OPEN_STATUSES), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; return this.transition( invoice.id, Freight.InvoiceStatus.Expired, "expired", {}, mg, ); } /** * Sync a source's open invoice `dueAt` to its real pay-window deadline. The * booking invoice is generated before the pay window opens (at booking * creation/approval), so its printed due date is refreshed when the batch engine * sets `paymentDeadline`. No-op when the source has no open invoice. */ async syncPayableDueDate( source: Freight.InvoiceSource, sourceId: string, dueAt: Date, type?: string, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { source, sourceId, status: In(OPEN_STATUSES), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); if (!invoice) return; await mg.update(Invoice, { id: invoice.id }, { dueAt }); } /** * Force an invoice to `status`, including issuing a still-DRAFT invoice * (stamping `issuedAt`) — unlike the other transitions here, this is a * blunt admin/workflow override, not a settlement. No-op when the invoice * is missing or already terminal (paid/cancelled/refunded/expired). */ async updateStatus( invoiceId: string, status: Freight.InvoiceStatus, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, }); if (!invoice) return; await mg.update( Invoice, { id: invoice.id }, { status, issuedAt: invoice.issuedAt ?? new Date() }, ); } // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** * Charge an invoice through the payment gateway. Billing is the single place * that turns "what is owed" (the invoice) into a payment intent — the domain * never talks to the payment service directly. Resolves the invoice by ID, * opens an intent for `invoice.balanceAmount` (so partial payments are honored), * records the intent id on the invoice (the settlement correlation key), and * returns the client action. * * When the provider settles synchronously, the invoice is settled inline here — * after the intent id is stored — so the `payment.succeeded` correlation can * never fire before the link exists. Throws when the invoice is not found or * not in an open/payable status. */ async payInvoice( invoiceId: string, opts: { method?: string; platform?: "web" | "mobile"; payerAccount?: string; returnUrl?: string; failureUrl?: string; } = {}, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId, status: In(OPEN_STATUSES) }, }); if (!invoice) { throw new NotFoundException( `Invoice ${invoiceId} not found or not in a payable status`, ); } const result = await this.payment.initiate({ referenceId: invoice.sourceId, source: invoice.source, // 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.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", platform: opts.platform, payerAccount: opts.payerAccount, returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); // Link the intent to the invoice BEFORE any settlement can correlate against it. await this.dataSource .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); if (result.immediateSuccess) { await this.settleByPaymentId( result.intentId, result.providerTxnId, result.paidAt, ); } return result.response; } /** * Settle the open invoice linked to a gateway intent id, if any. Called by the * payment service when an intent succeeds: finds the invoice linked by * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain * to advance on. Idempotent — no-op when no open invoice is linked (already * settled, or settled inline by {@link payInvoice}). */ async settleByPaymentId( paymentId: string, _providerTxnId?: string, _paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { paymentId, status: In(OPEN_STATUSES) }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; return this.markInvoiceAsPaid(invoice.id, paymentId); } }