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, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.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 { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; 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, sameCompanyName, InvoiceDocumentService, pngDataUrl, } from "./documents/invoice-document.service"; import { amountInWords } from "./documents/mor-document.util"; import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context"; import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto"; 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, invoicePaymentMethodExpr, round2, settlementReferences, } 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; } /** * What an invoice's `sourceId` actually points at, resolved for display. * * `source` alone ("warehouse", "booking", …) says which subsystem raised the * invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every * source except a shipping-line credit hangs off a booking — directly * (booking/clearance) or through the warehouse/first-mile/last-mile record — * so the booking reference is the one label that identifies almost any row. */ export interface InvoiceSourceRef { /** Booking behind the invoice, when there is one. Null for shipping-line credits. */ bookingId: string | null; bookingReference: string | null; tradeDirection: string | null; /** Warehouse-sourced rows: the goods-received note the fees were raised against. */ grnNumber: string | null; /** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */ shippingLineName: string | null; } /** Row shape of the backoffice invoice list: the entity plus its resolved source. */ export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null }; /** Booking context attached to a finance offline-USD invoice row. */ export interface OfflineUsdBookingInfo { id: string; reference: string; tradeDirection: string | null; paymentDeadline: Date | null; paymentStatus: string; } /** Row shape of the manual-payments worklist. */ export type OfflineUsdInvoiceRow = Invoice & { booking: OfflineUsdBookingInfo | null; /** Shipping-line credit invoices span many bookings — one entry per credit. */ bookings: { id: string; reference: string; tradeDirection: string | null }[]; }; /** 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`. */ /** * Every dimension the backoffice invoice list narrows by. `findAllPaginated` * and `collectedSummary` share it so the summary card can never total a * different set of invoices than the table below it shows. */ export interface InvoiceListFilters { companyId?: string; status?: Freight.InvoiceStatus; statuses?: Freight.InvoiceStatus[]; sources?: string[]; /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */ types?: string[]; eimsStatuses?: string[]; /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ paymentMethods?: string[]; currency?: string; search?: string; issuedFrom?: string; issuedTo?: string; dueFrom?: string; dueTo?: string; minAmount?: number; maxAmount?: number; hasBalance?: boolean; overdue?: boolean; /** Per-user trade-direction scope, applied via the source booking. */ tradeDirections?: string[]; } /** * The list/summary query builders both alias the invoice as `invoice` and the * joined gateway payment as `payment`; TypeORM rewrites those alias.property * references into real quoted columns. */ const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment"); 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; } /** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */ export type MemoType = "CRE" | "DEB"; /** Everything needed to issue a credit or debit memo against an already-registered invoice. */ export interface IssueMemoInput { type: MemoType; /** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */ reason: string; /** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */ lines?: InvoiceLineInput[]; } /** 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, private readonly manualPaymentSettings: ManualPaymentSettingsService, ) {} // ── 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). */ /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ private applyInvoiceFilters( qb: SelectQueryBuilder, filter: InvoiceListFilters, ) { if (filter.companyId) { qb.andWhere("invoice.companyId = :companyId", { companyId: filter.companyId, }); } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } if (filter.statuses?.length) { qb.andWhere("invoice.status IN (:...statuses)", { statuses: filter.statuses, }); } if (filter.sources?.length) { qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources, }); } if (filter.types?.length) { qb.andWhere("invoice.type IN (:...types)", { types: filter.types }); } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { eimsStatuses: filter.eimsStatuses, }); } if (filter.paymentMethods?.length) { // Requires the `payment` alias to be joined by the caller — both call // sites do, unconditionally, so this can never reference a missing alias. qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, { paymentMethods: filter.paymentMethods, }); } if (filter.currency) { // Stored casing has drifted ("usd" rows exist) — compare normalised. qb.andWhere("UPPER(invoice.currency) = :currency", { currency: filter.currency.toUpperCase(), }); } if (filter.issuedFrom) { qb.andWhere("invoice.issuedAt >= :issuedFrom", { issuedFrom: filter.issuedFrom, }); } if (filter.issuedTo) { qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo, }); } if (filter.dueFrom) { qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom }); } if (filter.dueTo) { qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo }); } if (filter.minAmount !== undefined) { qb.andWhere("invoice.totalAmount >= :minAmount", { minAmount: filter.minAmount, }); } if (filter.maxAmount !== undefined) { qb.andWhere("invoice.totalAmount <= :maxAmount", { maxAmount: filter.maxAmount, }); } if (filter.hasBalance) { qb.andWhere("invoice.balanceAmount > 0"); } if (filter.overdue) { // Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into // that status, so reading the column alone under-reports the arrears. qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()"); } if (filter.search) { // Searches what the row actually shows: its number, who it bills, the // source record behind it (booking reference, PNR, GRN, shipping line) // and the payment references a customer or a provider support desk would // quote back — the gateway transaction id and our merchant order id. // The raw `sourceId` stays matchable so a pasted UUID still resolves. // Requires the `company` and `payment` aliases — every caller joins both. qb.andWhere( `(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search OR company.name ILIKE :search OR payment.transactionId ILIKE :search OR payment.merchantOrderId ILIKE :search OR EXISTS ( SELECT 1 FROM freight.bookings b LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search) AND (b.id::text = invoice.source_id OR wi.id::text = invoice.source_id OR fm.id::text = invoice.source_id OR lm.id::text = invoice.source_id)) OR EXISTS ( SELECT 1 FROM freight.warehouse_inventory wi2 WHERE wi2.id::text = invoice.source_id AND wi2.grn_number ILIKE :search) OR EXISTS ( SELECT 1 FROM freight.shipping_line_companies slc WHERE slc.id::text = invoice.source_id AND slc.name ILIKE :search))`, { search: `%${filter.search}%` }, ); } if (filter.tradeDirections) { applyBookingRefDirectionScope( qb, "invoice.source_id", filter.tradeDirections, ); } return qb; } async findAllPaginated( filter: InvoiceListFilters & { page?: number; pageSize?: number; sortBy?: string; sortOrder?: "ASC" | "DESC"; } = {}, ): Promise<{ items: InvoiceListRow[]; 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") // The gateway payment behind the invoice: the settled method and the // provider's transaction reference both live on it, and nowhere else. .leftJoinAndSelect("invoice.payment", "payment") // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated // raw. The id tiebreaker keeps paging stable when the sort column ties // (issuedAt is null on every DRAFT row). .orderBy( INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt", filter.sortOrder ?? "DESC", ) .addOrderBy("invoice.id", "ASC") .skip((page - 1) * pageSize) .take(pageSize); this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); const withLines = await this.attachShippingLineCompanies(items); return { items: await this.attachSourceRefs(withLines), total }; } /** * Resolve each row's `sourceId` to the record it points at, in one query for * the whole page. `sourceId` is a bare varchar pointer with no FK and no * relation to eager-load, and which table it addresses depends on `source` — * so this walks every candidate table at once and lands on the booking * through whichever one matched. * * `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence * the shape guard before every cast — an unguarded `::uuid` throws on those. */ private async attachSourceRefs( invoices: T[], ): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> { const sourceIds = [ ...new Set(invoices.map((i) => i.sourceId).filter(Boolean)), ]; if (!sourceIds.length) { return invoices.map((invoice) => ({ ...invoice, sourceRef: null })); } const rows: { sourceId: string; bookingId: string | null; bookingReference: string | null; tradeDirection: string | null; grnNumber: string | null; shippingLineName: string | null; }[] = await this.dataSource.query( `SELECT s.source_id AS "sourceId", b.id::text AS "bookingId", b.reference AS "bookingReference", b.trade_direction AS "tradeDirection", wi.grn_number AS "grnNumber", slc.name AS "shippingLineName" FROM unnest($1::text[]) AS s(source_id) LEFT JOIN freight.warehouse_inventory wi ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' THEN s.source_id::uuid END) AND wi.deleted_at IS NULL LEFT JOIN freight.first_mile fm ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' THEN s.source_id::uuid END) AND fm.deleted_at IS NULL LEFT JOIN freight.last_mile lm ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' THEN s.source_id::uuid END) AND lm.deleted_at IS NULL LEFT JOIN freight.bookings b ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id, CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' THEN s.source_id::uuid END) AND b.deleted_at IS NULL LEFT JOIN freight.shipping_line_companies slc ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$' THEN s.source_id::uuid END) AND slc.deleted_at IS NULL`, [sourceIds], ); const bySourceId = new Map(rows.map((r) => [r.sourceId, r])); return invoices.map((invoice) => { const row = bySourceId.get(invoice.sourceId); const sourceRef: InvoiceSourceRef | null = row ? { bookingId: row.bookingId, bookingReference: row.bookingReference, tradeDirection: row.tradeDirection, grnNumber: row.grnNumber, shippingLineName: row.shippingLineName, } : null; // Nothing resolved (an EIMS self-test row, a deleted record) → null, // and the UI falls back to the plain source label. const resolved = sourceRef && (sourceRef.bookingId || sourceRef.grnNumber || sourceRef.shippingLineName) ? sourceRef : null; return { ...invoice, sourceRef: resolved }; }); } /** * Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping * line (`companyId` null). No relation on `Invoice` to eager-load — see the * entity's doc comment — so this is a second query keyed off the ids * already loaded, same shape as `company`. */ private async attachShippingLineCompanies( invoices: T[], ): Promise { const ids = [ ...new Set( invoices .map((i) => i.shippingLineCompanyId) .filter((id): id is string => id != null), ), ]; if (!ids.length) return invoices; const lines = await this.dataSource .getRepository(ShippingLineCompany) .find({ where: { id: In(ids) } }); const byId = new Map(lines.map((l) => [l.id, l])); return invoices.map((invoice) => { const line = invoice.shippingLineCompanyId ? byId.get(invoice.shippingLineCompanyId) : undefined; return line ? ({ ...invoice, shippingLineCompany: { id: line.id, name: line.name, email: line.email, phoneNumber: line.phoneNumber, }, } as T) : invoice; }); } /** * Total collected (`paidAmount`) across every invoice matching the same * filters as `findAllPaginated`, grouped by currency — unpaginated, so the * invoices summary card reflects the whole filtered set, not just the * visible page. */ async collectedSummary( filter: InvoiceListFilters = {}, ): Promise> { const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") // Joined, not selected: `applyInvoiceFilters` searches the customer name, // so the alias has to exist even though the summary only sums money. .leftJoin("invoice.company", "company") .leftJoin("invoice.payment", "payment") .select("invoice.currency", "currency") .addSelect("SUM(invoice.paidAmount)", "collected") .groupBy("invoice.currency"); this.applyInvoiceFilters(qb, filter); const rows: { currency: string; collected: string }[] = await qb.getRawMany(); return Object.fromEntries( rows.map((row) => [row.currency, Number(row.collected) || 0]), ); } /** * Finance's manual-settlement worklist: USD invoices (paid by bank transfer, * never through the gateway) and ETB invoices Finance settles by hand (bank * transfer / counter) instead of the customer paying online. Both currencies * unless `currency` narrows it, and only ones whose manual-payment channel is * switched on. Open ones by default — pin `status` or `statuses` to widen * that. Every other dimension is the invoice list's own (`applyInvoiceFilters` * + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike. * Booking-sourced rows carry the booking's reference, trade direction and * pay-window deadline so the UI can show the countdown and link to the * booking. */ async findOfflineUsdPaginated( filter: InvoiceListFilters & { page?: number; pageSize?: number; sortBy?: string; sortOrder?: "ASC" | "DESC"; } = {}, ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number; /** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */ outstanding: Record; }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; // Only currencies whose manual-payment channel is switched on are listed: // a row Finance cannot act on is noise, and the confirm endpoint would // refuse it anyway. All off → nothing to work. const enabled = await this.manualPaymentSettings.enabledCurrencies(); const empty = { items: [], total: 0, outstanding: {} }; if (!enabled.length) return empty; const wanted = filter.currency?.toUpperCase(); const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled; if (!currencies.length) return empty; /** * The worklist narrows by the same vocabulary as the main invoice list, so * both share `applyInvoiceFilters` — which references the `company` and * `payment` aliases, hence the unconditional joins. `select` is false for * the aggregate pass, where joined columns would break the GROUP BY. */ const buildQb = (select: boolean) => { const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice"); if (select) { qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect( "invoice.payment", "payment", ); } else { qb.leftJoin("invoice.company", "company").leftJoin( "invoice.payment", "payment", ); } qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies }); // "What still needs settling" is the default cut, but only until the // caller pins a status — either the single-status param or the filter // bar's multi-select. if (!filter.status && !filter.statuses?.length) { qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); } // `currency` is already enforced by the enabled-currency IN above, and // re-applying it would only repeat the same predicate. this.applyInvoiceFilters(qb, { ...filter, currency: undefined }); return qb; }; const qb = buildQb(true) // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated // raw; the id tiebreaker keeps paging stable when the column ties. .orderBy( INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt", filter.sortOrder ?? "DESC", ) .addOrderBy("invoice.id", "ASC") .skip((page - 1) * pageSize) .take(pageSize); const [rawItems, total] = await qb.getManyAndCount(); // Outstanding across the whole filtered set, not the visible page — the // KPI must not change as Finance pages through the worklist. const outstandingRows: { currency: string; outstanding: string }[] = await buildQb(false) .select("invoice.currency", "currency") .addSelect("SUM(invoice.balanceAmount)", "outstanding") .groupBy("invoice.currency") .getRawMany(); // Folded case-insensitively on the way out: stored casing has drifted // ("usd" rows exist), so two groups can address the same currency. const outstanding: Record = {}; for (const row of outstandingRows) { const key = (row.currency ?? "").toUpperCase(); outstanding[key] = (outstanding[key] ?? 0) + (Number(row.outstanding) || 0); } const items = await this.attachShippingLineCompanies(rawItems); 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", "tradeDirection", "paymentDeadline", "paymentStatus", ], }) : []; const byId = new Map(bookings.map((b) => [b.id, b])); // Shipping-line credit invoices bill many bookings at once; each credit // keeps its own booking link, so collect them per invoice. const creditInvoiceIds = items .filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit) .map((i) => i.id); const credits = creditInvoiceIds.length ? await this.dataSource.getRepository(ShippingLineCredit).find({ where: { invoiceId: In(creditInvoiceIds) }, relations: { booking: true }, }) : []; const bookingsByInvoice = new Map< string, OfflineUsdInvoiceRow["bookings"] >(); for (const c of credits) { if (!c.invoiceId || !c.booking) continue; const list = bookingsByInvoice.get(c.invoiceId) ?? []; list.push({ id: c.booking.id, reference: c.booking.reference, tradeDirection: c.booking.tradeDirection ?? null, }); bookingsByInvoice.set(c.invoiceId, list); } return { items: items.map((inv) => { const b = byId.get(inv.sourceId); return { ...inv, booking: b ? { id: b.id, reference: b.reference, tradeDirection: b.tradeDirection ?? null, paymentDeadline: b.paymentDeadline ?? null, paymentStatus: b.paymentStatus, } : null, bookings: bookingsByInvoice.get(inv.id) ?? [], } as OfflineUsdInvoiceRow; }), total, outstanding, }; } /** * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer * or counter payment. Refused when that currency's manual-payment channel is * switched off in settings. 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`); // The channel is a setting, not a role: even a permitted user cannot // settle by hand in a currency whose channel is switched off. if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { throw new BadRequestException( `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`, ); } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } // The pay window belongs to the freight invoice. A wagon-cancellation fee // rides source=booking but is raised on an ALREADY-PAID booking, so it // inherits a deadline that has long passed — guarding it would make the fee // permanently unsettleable. if ( invoice.source === "booking" && invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE ) { 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, payment: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const [hydrated] = await this.attachShippingLineCompanies([invoice]); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, order: { createdAt: "ASC" }, }); return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] }; } // ── Documents (central PDF) ────────────────────────────────────────────────── /** * Sealed PDF invoice for any source, rendered by the shared document service. `format` * validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input * boundary check, not a business rule. */ async document( id: string, format: "a4" | "thermal" = "a4", ): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); const model = await this.toDocumentModel(invoice, "INVOICE"); return format === "thermal" ? this.invoiceDocuments.renderThermal(model) : this.invoiceDocuments.render(model); } /** 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"), ); } /** 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 tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim(); 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 }, // The trade name of the eTrade licence THIS profile operates as. A TIN // holds many licences and the invoiced role (importer/exporter/forwarder) // is usually a different business from the one the company registered // under, so the buyer's name alone doesn't say which one was billed. // Suppressed when it just repeats the buyer name — most companies trade // under their registered name and a duplicate row helps nobody. ...(tradeName && !sameCompanyName(tradeName, invoice.company?.name) ? [{ label: "Buyer trade name", value: tradeName }] : []), { 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 }); // The provider's transaction number for the money actually received — CBE's `FT…`, // telebirr's receipt number, or the bank-slip reference a teller recorded manually. // It is what a payer holding a receipt can match this invoice against, and what // finance reconciles a bank statement with; without it a PAID invoice proves only // that EDR says it was paid. `findById` already loads the `payment` relation, so both // sources are in hand here — see settlementReferences for why both are read. const txnRefs = settlementReferences(invoice); if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs }); // PNR — the CBE_BILL reference the customer pays against, written onto the booking at // payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so // look it up by source id; only shown once a payment actually generated one. if (invoice.source === Freight.InvoiceSource.Booking) { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: invoice.sourceId }, select: ["id", "pnrCode"], }); if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode }); } 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) => { // Same resolver the filing used, so the printed Tax Code / Excise / Discount columns // state what MoR actually holds for this line. const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null; return { description: l.description ?? l.chargeType, category: l.chargeType, quantity: l.quantity, unitRate: l.unitRate, amount: l.amount, currency: l.currency, nature: eimsCfg?.invoice?.natureOfSupplies ?? null, uom: eimsCfg?.invoice?.unitDefault ?? null, taxCode: tax?.code ?? null, excise: tax?.exciseTaxValue ?? null, discount: tax?.discount ?? null, }; }), totals, qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null, }; } /** * The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees * when they scan the QR on the Ministry's portal. * * Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document * has to print whether or not it is registered yet, and printing must not depend on the gateway * being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was * actually filed, so the paper and the filing cannot disagree. */ private buildMorDetails( invoice: Invoice & { lines: InvoiceLine[] }, cfg: EimsConfig, ): InvoiceDocumentModel["mor"] { const seller = buildEimsSeller(cfg); const company = invoice.company; const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV"; // CREDIT until the money is in: the title states the sale's payment nature, not its status. const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount); const TITLES: Record = { INV: isCash ? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" } : { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" }, CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" }, DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" }, }; let total = 0; let excise = 0; let discount = 0; let vatAmount = 0; let vatTaxable = 0; for (const line of invoice.lines) { const tax = resolveLineTax(cfg, line.chargeType); const lineTotal = Number(line.amount); total += lineTotal; excise += tax.exciseTaxValue; discount += tax.discount; if (tax.ratePercent > 0) { vatTaxable += lineTotal; vatAmount += (lineTotal * tax.ratePercent) / 100; } } const totalIncludingTax = Number(invoice.totalAmount); const rate = cfg.invoice.taxRatePercent ?? 0; const title = TITLES[documentType] ?? TITLES.INV; return { titleAm: title.am, titleEn: title.en, saleType: cfg.invoice.transactionType, irn: invoice.eimsIrn, systemNumber: cfg.systemNumber || null, referenceNumber: invoice.eimsDocumentNumber ?? null, relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null, seller: { name: cfg.invoice.sellerLegalName || seller.LegalName, city: seller.City, subCity: seller.SubCity, woreda: seller.Wereda, kebele: seller.Locality, houseNo: seller.HouseNumber, tin: seller.Tin, vatNumber: seller.VatNumber, }, buyer: { name: company?.name ?? "N/A", city: company?.zone ?? null, subCity: company?.zone ?? null, woreda: company?.woreda ?? null, kebele: company?.kebele ?? null, houseNo: company?.houseNo ?? null, tin: company?.tin ?? null, vatNumber: company?.vatNumber ?? null, }, tax: { total: round2(total), discount: round2(discount), taxableTotal: round2(vatTaxable), excise: round2(excise), vatTaxableAmount: round2(vatTaxable), // An exempt seller still prints the row, labelled the way the Ministry's portal labels it. vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`, vatAmount: round2(vatAmount), incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0, vatWithholding: cfg.invoice.transactionWithholdValue ?? 0, totalIncludingTax: round2(totalIncludingTax), amountInWords: amountInWords(totalIncludingTax), }, payment: { mode: isCash ? "CASH" : "CREDIT", typeMethod: cfg.invoice.paymentTerm, receiverName: company?.name ?? null, }, // A memo is an amendment to a filed document; MoR's layout carries the sign-off that // authorised it. Names come from the recorded reason until an approval chain exists. approval: documentType === "INV" ? null : { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: 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. `code` * defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent * daily sequence (different prefix hashes to a different advisory lock, see * `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers. */ private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise { return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code, }); } /** * 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); } /** * Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed * DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`, * `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice` * unchanged: it has no side effects (no events, no notifications, no payment records — every * event in this service fires from `runTransition` on a *transition*, not on create), so a memo * is just an ordinary invoice with three extra columns set. * * `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId` * (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by * `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the * newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own * `id` is never a value those lookups are ever queried with, so this isolates a memo from all * of them regardless of its status — no `type`-based exclusion needed anywhere else. * * A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so * leaving it payable would only add a phantom receivable that no payment flow will ever close. * A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary * invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is * findable and collectible through the normal invoice list/detail/payment tooling, safe from * the CBE/booking-linked lookups above for the `sourceId` reason just given. */ async issueMemo( originalId: string, input: IssueMemoInput, ): Promise { const reason = input.reason?.trim(); if (!reason) { throw new BadRequestException("A memo requires a reason."); } const original = await this.findById(originalId); if (!original.eimsIrn) { throw new BadRequestException({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`, }); } if (original.eimsDocumentType && original.eimsDocumentType !== "INV") { throw new BadRequestException( `Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`, ); } if (original.eimsStatus === EimsInvoiceStatus.Cancelled) { throw new BadRequestException( `Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`, ); } const sourceLines = input.lines?.length ? input.lines : original.lines; const lines: InvoiceLineInput[] = sourceLines.map((l) => ({ chargeType: l.chargeType, description: l.description, quantity: Number(l.quantity), unitRate: Number(l.unitRate), amount: Number(l.amount), currency: l.currency, metadata: l.metadata ?? null, })); const total = round2( lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0), ); if (!(total > 0)) { throw new BadRequestException("A memo must have a positive total."); } // Only a credit note is bounded by the original — it can only give back what was charged. A // debit note is an additional charge, not a refund, so no such ceiling applies to it (do not // assume the credit-note ceiling is correct for DEB). if (input.type === "CRE" && total > Number(original.totalAmount)) { throw new BadRequestException( `Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`, ); } const code = input.type === "CRE" ? "CRE" : "DEB"; const settled = input.type === "CRE"; return this.dataSource.transaction(async (mg) => { const memo = await this.createInvoice( { source: original.source as Freight.InvoiceSource, sourceId: original.id, type: input.type === "CRE" ? "credit_note" : "debit_note", companyId: original.companyId, companyProfileId: original.companyProfileId, shippingLineCompanyId: original.shippingLineCompanyId, lines, currency: original.currency, subtotalAmount: total, taxAmount: 0, totalAmount: total, ...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}), }, mg, code, ); const patch: Record = { eimsDocumentType: input.type, eimsReason: reason, relatedInvoiceId: original.id, ...(settled ? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date(), } : {}), }; await mg.update(Invoice, memo.id, patch); this.logger.log( `Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`, ); return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] }; }); } private async createInvoice( input: GenerateInvoiceInput, mg: EntityManager, code = "INV", ): 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, code); 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 }); } // Same reference, for an ad-hoc additional charge — its own column, since // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking // can carry many of these at once. if ( billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge ) { await this.dataSource .getRepository(AdditionalCharge) .update({ id: invoice.sourceId }, { paymentReference: 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), }; } }