Files
edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.ts
2026-08-04 09:00:52 +00:00

1296 lines
47 KiB
TypeScript

import { Freight, PaymentReferenceType } from "@edr/types";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
method?: string;
platform?: "web" | "mobile";
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
amount: number;
method?: string | null;
reference?: string | null;
/** When the settlement occurred; defaults to now. */
paidAt?: Date;
metadata?: Record<string, unknown> | null;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/**
* 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<string, unknown> | null;
}
/** Everything needed to generate an invoice for any source. */
export interface GenerateInvoiceInput {
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
source: Freight.InvoiceSource;
/** Identifier of the source record (e.g. booking id). */
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
subtotalAmount?: number;
/** Tax applied on top of the subtotal; defaults to 0. */
taxAmount?: number;
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
dueInDays?: number;
/**
* Initial status. DRAFT leaves `issuedAt` null; any issued status
* (default PENDING) stamps `issuedAt`.
*/
status?: Freight.InvoiceStatus;
}
/** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload {
invoiceId: string;
invoiceNumber: string;
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
totalAmount: number;
currency: string;
status: Freight.InvoiceStatus;
paymentId?: string | null;
}
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
/** List every invoice (most recent first). */
findAll(): Promise<Invoice[]> {
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/**
* Paginated invoice list for the backoffice — optionally narrowed to a
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
} = {},
): Promise<{ items: Invoice[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
order: { createdAt: "ASC" },
});
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
const totals: InvoiceDocumentModel["totals"] = [
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
];
if (Number(invoice.taxAmount) > 0) {
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
}
totals.push({
label: "Total",
amount: Number(invoice.totalAmount),
grand: true,
});
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
})),
totals,
};
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
async resolveCompanyId(userId: string): Promise<string | null> {
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<Invoice[]> {
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<Invoice[]> {
if (!sourceIds.length) return Promise.resolve([]);
return this.invoices.findAll({
where: { source, sourceId: In(sourceIds) },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId, filter) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
}
/**
* Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
*/
async payInvoiceForUser(
id: string,
userId: string,
opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> {
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<IntentStatusDto> {
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<IntentStatusDto> {
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 ───────────────────────────────────────────────────────────────
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
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<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
return {
chargeType: l.chargeType,
description: l.description,
quantity,
unitRate,
amount: l.amount ?? quantity * unitRate,
currency: l.currency ?? currency,
metadata: l.metadata ?? null,
};
});
const subtotalAmount =
input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0;
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt =
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber,
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
paidAmount: 0,
balanceAmount: round2(totalAmount),
payments: [],
currency,
status,
issuedAt: issued ? new Date() : null,
dueAt,
}),
);
const savedLines = await Promise.all(
lines.map((l) =>
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
),
);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
);
return { ...invoice, lines: savedLines };
}
// ── State transitions ────────────────────────────────────────────────────────
/**
* Run `fn` inside a transaction and only emit its returned domain event
* after commit. When the caller passes their own `manager`, they own commit
* timing — `fn`'s event fires inline as soon as it resolves (the outer
* transaction may still roll back afterwards; this is the caller's
* documented tradeoff). When no `manager` is given, this opens its own
* transaction and defers the emit until after that transaction commits, so
* listeners (e.g. booking advancement) can never observe an invoice change
* that then rolls back.
*/
private async runTransition<T>(
manager: EntityManager | undefined,
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
): Promise<T> {
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<Invoice | null> {
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<Invoice> {
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<Invoice | null> {
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<Invoice | null> {
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.<event>`. 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<Invoice | null> {
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === status) return { result: invoice };
guard?.(invoice);
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent(event, updated),
};
});
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
private emitInvoiceEvent(event: string, invoice: Invoice): void {
const payload: InvoiceEventPayload = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source as Freight.InvoiceSource,
sourceId: invoice.sourceId,
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentId: invoice.paymentId ?? null,
};
this.events
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
.catch((err) =>
this.logger.error(
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
),
);
}
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a source record already has open, or null if it needs a new
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
* first-mile, last-mile) runs before generating — it must see DRAFT
* invoices too, not just issued ones, otherwise a source that already has
* an unissued draft gets a second, duplicate invoice minted alongside it
* instead of that draft being reused and then issued.
*
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching draft-or-open
* (unpaid, non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
): Promise<Invoice | null> {
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<Invoice | null> {
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<Invoice | null> {
// 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;
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<Invoice | null> {
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<void> {
// 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() },
);
}
// ── 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<InitiateResponseDto> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
relations: { company: true },
});
if (!invoice) {
throw new NotFoundException(
`Invoice ${invoiceId} not found or not in a payable status`,
);
}
// A booking's PREPAID invoice is only payable inside its pay window —
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
// Blocking INITIATION here is what makes the deadline real: a payment
// STARTED before this gate but settling late is still honored by the
// expire-time gateway reconcile. Other invoice types keep dueAt display-only.
if (
invoice.source === Freight.InvoiceSource.Booking &&
invoice.type === "PREPAID" &&
invoice.dueAt &&
invoice.dueAt.getTime() <= Date.now()
) {
throw new BadRequestException(
"The payment window for this booking has closed — the reserved wagons " +
"were released. Please book again.",
);
}
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
!opts.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,
// Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice
// link is the intent id (`paymentId`); per-source post-payment reactions live
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
// 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 });
// 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<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId },
// NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs
// first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
});
if (!invoice) return null;
const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES,
Freight.InvoiceStatus.Expired,
];
if (!settleable.includes(invoice.status)) {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly.
if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
`(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` +
`needs a refund or a manual settlement.`,
);
}
return null;
}
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,
});
}
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
*/
async billQuery(referenceId: string): Promise<{
stillPayable: boolean;
payerName?: string | null;
currentAmountMinor?: number | null;
currency?: string | null;
reason?: string | null;
paymentReason?: string | null;
}> {
const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
relations: { company: true },
order: { issuedAt: "DESC" },
});
if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
payerName: open.company?.name ?? null,
currentAmountMinor: balance,
currency: open.currency,
// CBE shows this beside the amount on the confirmation screen — the invoice number
// the payer is holding, not our internal reference.
paymentReason: `Freight invoice ${open.invoiceNumber}`,
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
// expired, and that is what the payer at the CBE counter must be told.
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
};
}
const latest = await repo.findOne({
where: { sourceId: referenceId },
relations: { company: true },
order: { createdAt: "DESC" },
});
// A bill reference whose invoice no longer exists at all — a data problem, not a
// cancellation the payer did anything to cause.
if (!latest) {
return {
stillPayable: false,
payerName: null,
currentAmountMinor: null,
currency: null,
reason: "NOT_FOUND",
};
}
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),
};
}
}