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 { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } 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, // Success-redirect ack; still unsettled, so it must stay payable/settleable. Freight.InvoiceStatus.PaymentProcessing, Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; /** * Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE * bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter, * so "cancelled" must not stand in for "already paid" or "refunded". */ function closedInvoiceReason(status: Freight.InvoiceStatus): string { switch (status) { case Freight.InvoiceStatus.Paid: return "ALREADY_PAID"; case Freight.InvoiceStatus.Refunded: return "REFUNDED"; case Freight.InvoiceStatus.Cancelled: return "CANCELLED"; case Freight.InvoiceStatus.Expired: return "EXPIRED"; // Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable". default: return "NOT_PAYABLE"; } } /** 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" } }); } /** * Paginated invoice list for the backoffice — optionally narrowed to a * company (customer detail "Invoices" tab) and/or status/search (global * invoices page). */ async findAllPaginated( filter: { companyId?: string; status?: Freight.InvoiceStatus; search?: string; page?: number; pageSize?: number; /** Per-user trade-direction scope, applied via the source booking. */ tradeDirections?: string[]; } = {}, ): Promise<{ items: Invoice[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); if (filter.companyId) { qb.andWhere("invoice.companyId = :companyId", { companyId: filter.companyId, }); } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } if (filter.search) { qb.andWhere( "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", { search: `%${filter.search}%` }, ); } if (filter.tradeDirections) { applyBookingRefDirectionScope( qb, "invoice.source_id", filter.tradeDirections, ); } const [items, total] = await qb.getManyAndCount(); return { items, total }; } /** Invoice header plus its line items. */ async findById(id: string): Promise { const invoice = await this.invoices.findById(id, { relations: { company: true, companyProfile: true }, }); 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 a batch of source records (e.g. many last-mile legs), so a * list can show which records already have an invoice without N+1 queries. */ findBySourceIds(source: string, sourceIds: string[]): Promise { if (!sourceIds.length) return Promise.resolve([]); return this.invoices.findAll({ where: { source, sourceId: In(sourceIds) }, 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); } /** * Submit the CAC Bank OTP for one of the customer's own invoices * (ownership-checked). Settlement of the invoice happens inside the payment * service when the OTP succeeds. */ async confirmInvoiceOtpForUser( id: string, userId: string, otp: string, ): Promise { await this.findByIdForUser(id, userId); return this.confirmInvoiceOtp(id, otp); } /** OTP confirmation by invoice id — the intent is the one stamped at initiate. */ async confirmInvoiceOtp( invoiceId: string, otp: string, ): Promise { const invoice = await this.dataSource .getRepository(Invoice) .findOne({ where: { id: invoiceId } }); if (!invoice?.paymentId) { throw new NotFoundException("No payment to confirm for this invoice"); } return this.payment.confirmOtp(invoice.paymentId, otp); } /** 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 { console.log("oooooooooo", input); 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 ──────────────────────────────────────────────────────── /** * Run `fn` inside a transaction and only emit its returned domain event * after commit. When the caller passes their own `manager`, they own commit * timing — `fn`'s event fires inline as soon as it resolves (the outer * transaction may still roll back afterwards; this is the caller's * documented tradeoff). When no `manager` is given, this opens its own * transaction and defers the emit until after that transaction commits, so * listeners (e.g. booking advancement) can never observe an invoice change * that then rolls back. */ private async runTransition( manager: EntityManager | undefined, fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>, ): Promise { if (manager) { const { result, emit } = await fn(manager); emit?.(); return result; } let pending: (() => void) | undefined; const result = await this.dataSource.transaction(async (mg) => { const out = await fn(mg); pending = out.emit; return out.result; }); pending?.(); return result; } /** * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, * append the settlement to the `payments` ledger, 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; otherwise locks the row for update and * emits only after commit (see {@link runTransition}). */ async markInvoiceAsPaid( invoiceId: string, paymentId: string | null = null, manager?: EntityManager, settlement: { providerTxnId?: string; paidAt?: Date } = {}, ): Promise { return this.runTransition(manager, async (mg) => { const invoice = await mg.findOne(Invoice, { where: { id: invoiceId }, lock: { mode: "pessimistic_write" }, }); if (!invoice) { throw new NotFoundException(`Invoice ${invoiceId} not found`); } if (invoice.status === Freight.InvoiceStatus.Paid) { return { result: invoice }; } const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date(); const settledAmount = round2( Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0), ); const entry: InvoicePayment = { amount: settledAmount, method: "GATEWAY", reference: settlement.providerTxnId ?? paymentId ?? null, paidAt: paidAt.toISOString(), metadata: null, }; const payments = [...(invoice.payments ?? []), entry]; const patch = { status: Freight.InvoiceStatus.Paid, paymentId, paidAt, paidAmount: invoice.totalAmount, balanceAmount: 0, payments, }; await mg.update(Invoice, { id: invoiceId }, patch as never); const updated = { ...invoice, ...patch } as Invoice; return { result: updated, emit: () => this.emitInvoiceEvent("paid", 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, `amount` is not positive, or `amount` exceeds the outstanding * balance. Pass `manager` to enlist in a caller's transaction; otherwise * locks the row for update and emits only after commit (see * {@link runTransition}). */ async recordPayment( invoiceId: string, input: RecordPaymentInput, manager?: EntityManager, ): Promise { if (!(input.amount > 0)) { throw new BadRequestException( "Payment amount must be greater than zero.", ); } return this.runTransition(manager, async (mg) => { const invoice = await mg.findOne(Invoice, { where: { id: invoiceId }, lock: { mode: "pessimistic_write" }, }); 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."); } // M27: a Draft invoice is not yet issued and an Expired invoice's pay // window has closed — neither is payable. Without these guards a payment // could settle an unissued draft or a lapsed invoice. if (invoice.status === Freight.InvoiceStatus.Draft) { throw new BadRequestException( "Cannot pay a draft invoice — it must be issued first.", ); } if (invoice.status === Freight.InvoiceStatus.Expired) { throw new BadRequestException( "Cannot pay an expired invoice — its payment window has closed.", ); } if (round2(input.amount) > Number(invoice.balanceAmount)) { throw new BadRequestException( `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, ); } 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]; const patch = { paidAmount, balanceAmount, status, payments, paidAt: fullyPaid ? at : (invoice.paidAt ?? null), }; await mg.update(Invoice, { id: invoice.id }, patch as never); const updated = { ...invoice, ...patch } as Invoice; return { result: updated, emit: fullyPaid ? () => this.emitInvoiceEvent("paid", updated) : undefined, }; }); } /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. * No-op when already refunded. Throws when the invoice has no recorded * payment (nothing to refund). */ async markInvoiceAsRefunded( invoiceId: string, manager?: EntityManager, ): Promise { return this.transition( invoiceId, Freight.InvoiceStatus.Refunded, "refunded", {}, manager, (invoice) => { if (!(Number(invoice.paidAmount) > 0)) { throw new BadRequestException( "Cannot refund an invoice with no recorded payment.", ); } }, ); } /** * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. * No-op when already cancelled. Throws when the invoice has payments * recorded against it (refund it instead). */ async cancelInvoice( invoiceId: string, manager?: EntityManager, ): Promise { return this.transition( invoiceId, Freight.InvoiceStatus.Cancelled, "cancelled", {}, manager, (invoice) => { if (Number(invoice.paidAmount) > 0) { throw new BadRequestException( "Cannot cancel an invoice that has payments recorded against it.", ); } }, ); } /** * Load the invoice, apply the new status (+ extra columns), then emit * `${source}.invoice.`. No-op (returns the invoice, skipping `guard`) * when it is already in the target status. Throws when the invoice does not * exist or `guard` rejects the current state. Pass `manager` to enlist in a * caller's transaction; otherwise locks the row for update and emits only * after commit (see {@link runTransition}). */ private async transition( invoiceId: string, status: Freight.InvoiceStatus, event: string, extra: { paymentId?: string }, manager?: EntityManager, guard?: (invoice: Invoice) => void, ): Promise { return this.runTransition(manager, async (mg) => { const invoice = await mg.findOne(Invoice, { where: { id: invoiceId }, lock: { mode: "pessimistic_write" }, }); if (!invoice) { throw new NotFoundException(`Invoice ${invoiceId} not found`); } if (invoice.status === status) return { result: invoice }; guard?.(invoice); await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); const updated = { ...invoice, ...extra, status } as Invoice; return { result: updated, emit: () => this.emitInvoiceEvent(event, 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 .emitAsync(`${invoice.source}.invoice.${event}`, payload) .catch((err) => this.logger.error( `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`, ), ); } // ── Payment reconciliation (by source) ─────────────────────────────────────── /** * The invoice a source record already has open, or null if it needs a new * one. This is the idempotency check every `ensureInvoiceFor*` (booking, * first-mile, last-mile) runs before generating — it must see DRAFT * invoices too, not just issued ones, otherwise a source that already has * an unissued draft gets a second, duplicate invoice minted alongside it * instead of that draft being reused and then issued. * * 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 draft-or-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([Freight.InvoiceStatus.Draft, ...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 invoice left to * retire (already paid/cancelled/expired). * * DRAFT invoices are matched too, even though they were never issued: this is * also the "retire the invoice this source no longer needs" path (a cancelled * booking, or a full-amount invoice superseded by a partial-offer one). Skipping * drafts would leave the stale one behind for `findPayable` to hand back — the * superseding invoice would then never be minted, and a cancelled booking would * keep a draft that a later `issuePayable` could still make payable. * * 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 { // Lookup can use the default manager (no lock). But the pessimistic-lock write // inside `transition` NEEDS an open transaction: pass the caller's `manager` // through untouched (undefined when there is no caller txn) so `runTransition` // opens its own. Passing `this.dataSource.manager` here made `runTransition` // treat it as an already-open transaction and skip wrapping — the lock then // threw `An open transaction is required for pessimistic lock`, aborting the // whole settle pass (the "reservations settle/reserve one at a time" symptom). const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { source, sourceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; // Reconcile-before-expire, caller-proof: an invoice with a payment intent may // have settled at the gateway without the webhook landing yet. `paid` — leave // it open, the (re-emitted) payment.succeeded settles it. `unverifiable` — // never expire on unknown; the caller's next sweep retries. Invoices with no // intent (`paymentId` null) were never payable at a gateway and expire directly. if (invoice.paymentId) { const { paid, unverifiable } = await this.reconcilePayable( invoice.sourceId, ); if (paid || unverifiable) { this.logger.warn( `expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` + (paid ? "gateway reconcile found a settled payment" : "settlement unverifiable at the gateway"), ); return null; } } return this.transition( invoice.id, Freight.InvoiceStatus.Expired, "expired", {}, manager, ); } /** * Issue a source's invoice and stamp its real pay-window deadline — the single * transition that makes a source payable. * * A source's invoice is minted DRAFT, before any pay window exists (e.g. a * booking invoice is generated at creation / operation-accept, long before the * batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`, * so such an invoice is not settleable and the portal renders no pay button. * The domain calls this at the moment the pay window actually opens (booking → * `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues * the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`. * * Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so * a re-reserve never re-issues. No-op (returns null) when the source has no * draft-or-open invoice (already paid/cancelled/expired). */ async issuePayable( 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([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; const issuing = invoice.status === Freight.InvoiceStatus.Draft; const patch = { dueAt, ...(issuing ? { status: Freight.InvoiceStatus.Pending, issuedAt: invoice.issuedAt ?? new Date(), } : {}), }; await mg.update(Invoice, { id: invoice.id }, patch); if (issuing) { this.logger.log( `Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`, ); } return { ...invoice, ...patch } as Invoice; } /** * 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 { // M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but // does NOT touch paidAmount/balanceAmount. Its only legitimate use is the // Draft → Pending/Issued issue transition. It must NEVER mark an invoice // Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry // balance implications and must go through the dedicated settlement methods // (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable). if ( status !== Freight.InvoiceStatus.Pending && status !== Freight.InvoiceStatus.Issued ) { throw new BadRequestException( `updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`, ); } 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() }, ); } /** * Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move * the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from * ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and * is idempotent. Balance untouched: this is a display state, not a * settlement; settleByPaymentId still performs the real transition. */ async markInvoicePaymentProcessing(paymentId: string): Promise { const repo = this.dataSource.getRepository(Invoice); const invoices = await repo.findBy({ paymentId, status: In([ Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, ]), }); for (const invoice of invoices) { await repo.update( { id: invoice.id, status: invoice.status }, { status: Freight.InvoiceStatus.PaymentProcessing }, ); this.emitInvoiceEvent("payment-processing", { ...invoice, status: Freight.InvoiceStatus.PaymentProcessing, } as Invoice); } } /** * Counterpart of {@link markInvoicePaymentProcessing} for a failed intent: * PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a * retry. No-op from any other status. */ async revertInvoicePaymentProcessing(paymentId: string): Promise { const repo = this.dataSource.getRepository(Invoice); const invoices = await repo.findBy({ paymentId, status: Freight.InvoiceStatus.PaymentProcessing, }); for (const invoice of invoices) { await repo.update( { id: invoice.id, status: Freight.InvoiceStatus.PaymentProcessing }, { status: Freight.InvoiceStatus.Pending }, ); this.emitInvoiceEvent("payment-processing-reverted", { ...invoice, status: Freight.InvoiceStatus.Pending, } as Invoice); } } // ── 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. */ /** * Settlement check before expiring a payable order (reconcile-before-expire): * live-queries the gateway for any settled intent on the source order. Kept * on billing so the domain never talks to the payment service directly. */ reconcilePayable( sourceId: string, ): Promise<{ paid: boolean; unverifiable: boolean }> { return this.payment.reconcileShipment(sourceId); } 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) }, relations: { company: true }, }); if (!invoice) { throw new NotFoundException( `Invoice ${invoiceId} not found or not in a payable status`, ); } // A booking's PREPAID invoice is only payable inside its pay window — // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). // Blocking INITIATION here is what makes the deadline real: a payment // STARTED before this gate but settling late is still honored by the // expire-time gateway reconcile. Other invoice types keep dueAt display-only. if ( invoice.source === Freight.InvoiceSource.Booking && invoice.type === "PREPAID" && invoice.dueAt && invoice.dueAt.getTime() <= Date.now() ) { throw new BadRequestException( "The payment window for this booking has closed — the reserved wagons " + "were released. Please book again.", ); } const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); if (!(amountDue > 0)) { throw new BadRequestException("Invoice has no outstanding balance."); } // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is // required up front (the payment service rejects it otherwise, as a 502 here). if ( (opts.method ?? "").toUpperCase() === "CAC_BANK" && !opts.payerAccount?.trim() ) { throw new BadRequestException( "payerAccount (mobile number) is required for CAC Bank", ); } 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.replace(/-/g, "_"), // Whole birr, always UP. CBE bills this amount verbatim, so it must never // land below the outstanding balance — Math.round would let a .40 balance // settle 0.40 short. Ceil overcharges by <1 birr instead, and the same // ceil in billQuery keeps the quoted and debited amounts identical. amountMinor: Math.ceil(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", platform: opts.platform, payerAccount: opts.payerAccount, // CBE_BILL: payer identity + the invoice's own due date as the bill expiry // (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4). payerName: invoice.company?.name, expiresAt: invoice.dueAt?.toISOString(), 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 }); // CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against // at any CBE channel. Persist it on the booking so it survives the initiate response and // shows on the booking/contract everywhere. The payment service reissues the same reference // while the bill stays open, so re-initiating overwrites with an identical value. const billReference = result.response.clientAction?.billReference; if (billReference && invoice.source === Freight.InvoiceSource.Booking) { await this.dataSource .getRepository(Booking) .update({ id: invoice.sourceId }, { pnrCode: billReference }); } // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the // code — so the demo shortcut must never fire for it. Same for CBE_BILL: its // bill must stay open until CBE actually settles it via /cbe/payment. // if ( // !result.immediateSuccess && // result.response.clientAction?.type !== "COLLECT_OTP" && // opts.method !== "CBE_BILL" // ) { // await this.payment.handlePaymentEvent({ // eventType: "payment.succeeded", // eventId: `demo-${result.intentId}`, // referenceId: invoice.sourceId, // intentId: result.intentId, // providerTxnId: result.providerTxnId, // paidAt: (result.paidAt ?? new Date()).toISOString(), // }); // } 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}). * * EXPIRED is settleable HERE and only here: this is the gateway path, so the * money is already captured and we are recording a fait accompli. A success can * land after the pay window plus its drain tail (relay backlog, payment-api * restart, a CBE bill paid at a counter) — matching only `OPEN_STATUSES` used to * drop it silently, leaving a debited customer with an EXPIRED invoice and no * alert. The manual/offline path ({@link recordPayment}) keeps its EXPIRED guard: * a teller must not accept cash against a lapsed invoice. * * The status is checked on the RESOLVED invoice, never inside the lookup. * `paymentId` is freight's local intent projection, and `upsertIntent` keeps ONE * row per booking reference across every pay attempt — so a booking that was * re-invoiced after a lapsed attempt has SEVERAL invoices carrying the same * `paymentId`. Filtering by status inside the query would let a late capture from * attempt 1 skip past the already-PAID attempt-2 invoice and settle the older * EXPIRED one, marking two invoices paid off a single capture. Resolving the * newest invoice first and then asking whether IT is settleable makes the answer * "this booking's money is already recorded" instead. CANCELLED/REFUNDED are * refund cases, not settlements, and are logged rather than settled. */ async settleByPaymentId( paymentId: string, providerTxnId?: string, paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { paymentId }, // NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs // first on DESC, which would hand back an unissued invoice. order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, }); if (!invoice) return null; const settleable: Freight.InvoiceStatus[] = [ ...OPEN_STATUSES, Freight.InvoiceStatus.Expired, ]; if (!settleable.includes(invoice.status)) { // Already PAID is the ordinary idempotent no-op (redelivery, or settled // inline by payInvoice). Anything else means money was captured with // nowhere to land — that needs a person, so say so loudly. if (invoice.status !== Freight.InvoiceStatus.Paid) { this.logger.error( `Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + `(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` + `needs a refund or a manual settlement.`, ); } return null; } return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { providerTxnId, paidAt, }); } /** * CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for * the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId, * i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting. */ async billQuery(referenceId: string): Promise<{ stillPayable: boolean; payerName?: string | null; currentAmountMinor?: number | null; currency?: string | null; reason?: string | null; paymentReason?: string | null; }> { const repo = this.dataSource.getRepository(Invoice); const open = await repo.findOne({ where: { sourceId: referenceId, status: In(OPEN_STATUSES) }, relations: { company: true }, order: { issuedAt: "DESC" }, }); if (open) { // Ceil, matching payInvoice — the amount CBE quotes at the counter has to // be the amount the intent was opened for, or /cbe/payment sees a mismatch. const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount)); const expired = open.dueAt && open.dueAt.getTime() < Date.now(); return { stillPayable: balance > 0 && !expired, payerName: open.company?.name ?? null, currentAmountMinor: balance, currency: open.currency, // CBE shows this beside the amount on the confirmation screen — the invoice number // the payer is holding, not our internal reference. paymentReason: `Freight invoice ${open.invoiceNumber}`, // Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not // expired, and that is what the payer at the CBE counter must be told. reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID", }; } const latest = await repo.findOne({ where: { sourceId: referenceId }, relations: { company: true }, order: { createdAt: "DESC" }, }); // A bill reference whose invoice no longer exists at all — a data problem, not a // cancellation the payer did anything to cause. if (!latest) { return { stillPayable: false, payerName: null, currentAmountMinor: null, currency: null, reason: "NOT_FOUND", }; } return { stillPayable: false, payerName: latest.company?.name ?? null, currentAmountMinor: Math.ceil(Number(latest.totalAmount)), currency: latest.currency, paymentReason: `Freight invoice ${latest.invoiceNumber}`, reason: closedInvoiceReason(latest.status), }; } }