import { Freight, PaymentReferenceType } from "@edr/types"; import { ConfigService } from "@nestjs/config"; import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.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; } /** Booking context attached to a finance offline-USD invoice row. */ export interface OfflineUsdBookingInfo { id: string; reference: string; paymentDeadline: Date | null; paymentStatus: 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; /** The customer billed. Omit only when billing a shipping line instead. */ companyId?: string | null; companyProfileId?: string | null; /** * The shipping line billed, for an invoice covering batched shipping-line * credits. Mutually exclusive with `companyId` — the DB enforces this via * `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload * setting both or neither before it ever reaches the constraint. */ shippingLineCompanyId?: string | null; 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; /** Null when the payer is a shipping line rather than a customer company. */ companyId: string | null; companyProfileId: string | null; /** Set only on shipping-line invoices; mutually exclusive with `companyId`. */ shippingLineCompanyId?: string | null; 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, private readonly files: FilesService, private readonly config: ConfigService, ) { } // ── 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 }; } /** * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, * never through the gateway), open ones by default or a single status when * filtered. Booking-sourced rows carry the booking's reference and pay-window * deadline so the UI can show the countdown and link to the booking. */ async findOfflineUsdPaginated( filter: { status?: Freight.InvoiceStatus; search?: string; page?: number; pageSize?: number; } = {}, ): Promise<{ items: (Invoice & { booking: OfflineUsdBookingInfo | null })[]; 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") .where("UPPER(invoice.currency) = 'USD'") .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); } if (filter.search) { qb.andWhere( "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", { search: `%${filter.search}%` }, ); } const [items, total] = await qb.getManyAndCount(); const bookingIds = items .filter((i) => i.source === "booking") .map((i) => i.sourceId); const bookings = bookingIds.length ? await this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, select: ["id", "reference", "paymentDeadline", "paymentStatus"], }) : []; const byId = new Map(bookings.map((b) => [b.id, b])); return { items: items.map((inv) => { const b = byId.get(inv.sourceId); return { ...inv, booking: b ? { id: b.id, reference: b.reference, paymentDeadline: b.paymentDeadline ?? null, paymentStatus: b.paymentStatus, } : null, } as Invoice & { booking: OfflineUsdBookingInfo | null }; }), total, }; } /** * Finance confirms a USD invoice as paid by bank transfer: stores the slip * against the invoice and settles the FULL outstanding balance through * {@link recordPayment}, which flips the invoice to PAID and (for bookings) * emits `booking.invoice.paid` — the same event an online payment fires, so * the booking advances exactly as if it had been paid through the gateway. * * Guarded by the booking's pay window: past the deadline the booking expires * like any unpaid one, so confirmation is refused. */ async confirmOfflinePayment( invoiceId: string, file: Express.Multer.File | undefined, input: { reference?: string | null; userId?: string | null; userName?: string | null; }, ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); if (invoice.currency?.toUpperCase() !== "USD") { throw new BadRequestException( "Offline confirmation is only for USD invoices — this invoice is paid online.", ); } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } if (invoice.source === "booking") { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: invoice.sourceId }, select: ["id", "paymentDeadline"], }); const deadline = booking?.paymentDeadline; if (deadline && new Date(deadline).getTime() < Date.now()) { throw new BadRequestException( "The payment window has closed — this booking can no longer be confirmed as paid.", ); } } const slip = await this.files.upload({ resource: "invoice", resourceId: invoice.id, code: "OFFLINE_PAYMENT_SLIP", file, title: "Bank payment slip", uploadedByUserId: input.userId ?? null, uploadedByName: input.userName ?? null, }); return this.recordPayment(invoiceId, { amount: Number(invoice.balanceAmount), method: "BANK_TRANSFER", reference: input.reference || slip.name, metadata: { offline: true, slipFileId: slip.id, confirmedByUserId: input.userId ?? null, confirmedByName: input.userName ?? null, }, }); } /** 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( await 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( await this.toDocumentModel(invoice, "RECEIPT"), ); } /** * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), * not a payload we encode ourselves. Wrapped in a data URL, nothing more. */ private renderEimsQr(signedQr: string): string { return `data:image/png;base64,${signedQr}`; } /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ private async bookingSummaryRows( invoice: Invoice, ): Promise { if (invoice.source !== Freight.InvoiceSource.Booking) return []; const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: invoice.sourceId }, relations: { originYard: true, destinationYard: true }, }); if (!booking) return []; return [ { label: "Route", value: booking.originYard && booking.destinationYard ? `${booking.originYard.label} → ${booking.destinationYard.label}` : null, }, { label: "Wagons", value: booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, }, ]; } /** Map a global invoice (+ lines) onto the source-agnostic document model. */ private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, kind: "INVOICE" | "RECEIPT", ): Promise { 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) }); const summary: InvoiceDocumentModel["summary"] = [ // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was // filed against, not just the seller. VatNumber shown only when the company has one. { label: "Buyer", value: invoice.company?.name ?? null }, { label: "Buyer TIN", value: invoice.company?.tin ?? null }, ...(invoice.company?.vatNumber ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }] : []), { label: "Status", value: invoice.status }, { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, ...(await this.bookingSummaryRows(invoice)), { 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, }, ]; // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this // codebase). Shown only when actually configured, same as the buyer VAT row. const eimsCfg = this.config.get("eims"); if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); if (eimsCfg?.invoice?.sellerVatNumber) { summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); } // MoR EIMS reference — only once actually registered, never a placeholder row. if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); return { kind, title, documentNumber: invoice.invoiceNumber, issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, summary, 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, qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, }; } // ── 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" }, }); } /** * Resolve a shipping-line company from the signed-in user (null for ordinary * customers). Queried straight off the entity rather than through * ShippingLineCompaniesService — that module already imports billing, so a * service edge back would deepen the forwardRef cycle for one lookup. */ private async resolveShippingLineCompanyId( userId: string, ): Promise { const line = await this.dataSource .getRepository(ShippingLineCompany) .findOne({ where: { userId } }); return line?.id ?? null; } /** * Invoices for the signed-in portal user; empty when they have no company. * A payer is either a customer company or a shipping line (enforced by the * DB's single-payer check), so the two lookups cannot both match. */ async findForUser( userId: string, filter: { source?: string; sourceId?: string } = {}, ): Promise { const companyId = await this.resolveCompanyId(userId); if (companyId) return this.findByCompany(companyId, filter); const shippingLineCompanyId = await this.resolveShippingLineCompanyId(userId); if (!shippingLineCompanyId) return []; return this.invoices.findAll({ where: { shippingLineCompanyId, ...(filter.source ? { source: filter.source } : {}), ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), }, order: { createdAt: "DESC" }, }); } /** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */ async findByIdForUser( id: string, userId: string, ): Promise { const invoice = await this.findById(id); const ownedByCompany = invoice.companyId != null && invoice.companyId === (await this.resolveCompanyId(userId)); const ownedByShippingLine = !ownedByCompany && invoice.shippingLineCompanyId != null && invoice.shippingLineCompanyId === (await this.resolveShippingLineCompanyId(userId)); if (!ownedByCompany && !ownedByShippingLine) { 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 { 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; // Exactly one payer, checked here so a bad payload fails with a clear // message instead of a raw `chk_invoices_single_payer` violation. const billsCompany = Boolean(input.companyId); const billsShippingLine = Boolean(input.shippingLineCompanyId); if (billsCompany === billsShippingLine) { throw new BadRequestException( "An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.", ); } if (billsCompany && !input.companyProfileId) { throw new BadRequestException( "companyProfileId is required when billing a company.", ); } 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 ?? null, companyProfileId: input.companyProfileId ?? null, shippingLineCompanyId: input.shippingLineCompanyId ?? null, 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 }); // Every invoice status move in the app funnels through here — money // changing state is the single most-asked question in support. logCtx( { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, source: invoice.source, sourceId: invoice.sourceId, from: invoice.status, to: status, event, amount: Number(invoice.totalAmount), currency: invoice.currency, paymentId: extra.paymentId ?? invoice.paymentId ?? undefined, }, { path: "invoiceTransitions", mode: "push" }, ); 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, shippingLineCompanyId: invoice.shippingLineCompanyId ?? null, 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."); } logCtx( { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, source: invoice.source, sourceId: invoice.sourceId, companyId: invoice.companyId, amountDue, currency: invoice.currency, method: opts.method ?? "TELEBIRR", platform: opts.platform, }, { path: "payment.payInvoice" }, ); // 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, "_"), // Exact balance, cents included. CBE bills this verbatim and /cbe/payment // matches the debited amount to the cent (amountsMatchToTheCent), so any // rounding here would overcharge the payer and leave the invoice balance // non-zero. billQuery quotes the same unrounded value. amountMinor: round2(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) { logCtx( { paymentId, outcome: "no-invoice-for-payment" }, { path: "payment.settleInvoice" }, ); return null; } logCtx( { paymentId, providerTxnId, invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, invoiceStatus: invoice.status, }, { path: "payment.settleInvoice" }, ); 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. logCtx( invoice.status === Freight.InvoiceStatus.Paid ? "already-paid" : "captured-with-nowhere-to-land", { path: "payment.settleInvoice.outcome", mode: "set" }, ); 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) { // Unrounded, matching payInvoice — the amount CBE quotes at the counter has // to be the amount the intent was opened for, to the cent, or /cbe/payment // sees a mismatch. const balance = round2(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: round2(Number(latest.totalAmount)), currency: latest.currency, paymentReason: `Freight invoice ${latest.invoiceNumber}`, reason: closedInvoiceReason(latest.status), }; } }