feat: setup billing services

This commit is contained in:
Nathnael
2026-06-29 07:11:52 +00:00
parent db305d6fcd
commit 5bad245ce8
5 changed files with 419 additions and 338 deletions

View File

@@ -1,57 +1,74 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
/** Source tag stamped on booking invoices (`source` column). */
const BOOKING_SOURCE = "booking";
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/**
* Statuses an invoice can hold while it still represents the live bill for a
* booking. A second `generateForBooking` call returns the existing one of these
* instead of creating a duplicate (idempotency / dedup guard).
*/
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Paid,
Freight.InvoiceStatus.Overdue,
];
/** Shape of a single line inside `booking.pricingBreakdown.lineItems`. */
interface StoredPriceLine {
code: string;
description: string;
amount: number;
unitAmount: number;
unit: string;
quantity: number;
currency: string;
}
interface StoredPricingBreakdown {
lineItems?: StoredPriceLine[];
totalAmount?: number;
currency?: string;
}
/** A fully-resolved invoice line ready to persist. */
interface BuiltLine {
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
description: string;
quantity: number;
unitRate: number;
amount: number;
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 total; defaults to the sum of line amounts. */
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;
metadata?: Record<string, unknown>;
status: Freight.InvoiceStatus;
paymentId?: string | null;
}
@Injectable()
@@ -62,7 +79,8 @@ export class BillingService {
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
) {}
private readonly events: EventEmitter2,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -71,14 +89,6 @@ export class BillingService {
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoices.findAll({
where: { source: BOOKING_SOURCE, sourceId: bookingId },
order: { issuedAt: "DESC" },
});
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
@@ -92,126 +102,6 @@ export class BillingService {
// ── Generation ───────────────────────────────────────────────────────────────
/**
* Generate the booking's invoice from its snapshotted pricing breakdown.
*
* Called when a booking reaches a billable state (full contract execution /
* contract activation). Idempotent: a booking that already has an active
* invoice gets that invoice back instead of a duplicate.
*
* Returns `null` (and logs) when the booking is not billable yet — no pricing
* breakdown, or no customer company/profile to bill (e.g. government/legacy
* bookings whose `company_id` is null, which the `invoices` FK requires).
*/
async generateForBooking(bookingId: string): Promise<Invoice | null> {
const existing = await this.invoices.findAll({
where: { source: BOOKING_SOURCE, sourceId: bookingId },
});
const active = existing.find((inv) => ACTIVE_STATUSES.includes(inv.status));
if (active) return active;
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${bookingId}): no company to bill.`,
);
return null;
}
const companyId = booking.companyId;
const companyProfileId = booking.companyProfileId ?? null;
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
const lines = this.buildLines(breakdown, booking.totalAmount, currency);
const subtotal = round2(lines.reduce((sum, l) => sum + l.amount, 0));
// Honor a staff price override: bill the adjusted total, recording the delta
// as an ADJUSTMENT line so the signed lines still sum to the invoice total.
const adjusted = booking.adjustedTotalAmount;
let total = subtotal;
if (adjusted != null && Number.isFinite(Number(adjusted))) {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: "ADJUSTMENT",
description: "Staff price adjustment",
quantity: 1,
unitRate: delta,
amount: delta,
currency,
});
}
total = round2(Number(adjusted));
}
const issuedAt = new Date();
const dueAt = new Date(issuedAt);
dueAt.setDate(dueAt.getDate() + DEFAULT_DUE_DAYS);
return this.dataSource.transaction(async (mg) => {
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber: await this.nextInvoiceNumber(mg),
companyId,
companyProfileId,
totalAmount: total,
currency,
status: Freight.InvoiceStatus.Pending,
source: BOOKING_SOURCE,
sourceId: bookingId,
type: "PREPAID",
issuedAt,
dueAt,
}),
);
for (const line of lines) {
await mg.save(mg.create(InvoiceLine, { invoiceId: invoice.id, ...line }));
}
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} for booking ${booking.reference} (${total} ${currency}).`,
);
return invoice;
});
}
/** Map the stored pricing line items to invoice lines (one breakdown line → one invoice line). */
private buildLines(
breakdown: StoredPricingBreakdown,
fallbackTotal: number,
currency: string,
): BuiltLine[] {
const items = breakdown.lineItems ?? [];
if (items.length === 0) {
// No itemized breakdown — bill a single line for the booking total.
return [
{
chargeType: "FREIGHT",
description: "Freight charge",
quantity: 1,
unitRate: round2(fallbackTotal),
amount: round2(fallbackTotal),
currency,
},
];
}
return items.map((item) => ({
chargeType: item.code,
description: item.description,
quantity: item.quantity,
unitRate: round2(item.unitAmount),
amount: round2(item.amount),
currency: item.currency ?? currency,
metadata: { unit: item.unit },
}));
}
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
@@ -226,108 +116,254 @@ export class BillingService {
return `${prefix}${String(next).padStart(5, "0")}`;
}
// ── Payment reconciliation ───────────────────────────────────────────────────
/**
* The invoice a gateway payment should settle for a booking, or null if none.
* Generate an invoice for any source (booking, demurrage, manual, …).
*
* This is the billing document of record for "what is owed" — callers (e.g.
* `payment.service.initiatePayment`) should charge `invoice.totalAmount` against
* it rather than recomputing from `booking.totalAmount`, so discounts/penalties/
* adjustments carried on the invoice are honored. Returns the most recent open
* (unpaid, non-cancelled) invoice.
* 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).
*/
findPayableForBooking(bookingId: string): Promise<Invoice | null> {
return this.dataSource.getRepository(Invoice).findOne({
where: {
source: BOOKING_SOURCE,
sourceId: bookingId,
status: In([
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Overdue,
]),
},
order: { issuedAt: "DESC" },
});
}
/**
* Mark a booking's paid invoice as refunded. Called from `payment.service.refund`
* inside its DB transaction so the invoice tracks the booking/payment reversal.
* No-op when the booking has no paid invoice.
*/
async markBookingInvoiceRefunded(
bookingId: string,
async generateInvoice(
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source: BOOKING_SOURCE,
sourceId: bookingId,
status: Freight.InvoiceStatus.Paid,
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(
Invoice,
{ id: invoice.id },
{ status: Freight.InvoiceStatus.Refunded },
);
): Promise<Invoice & { lines: InvoiceLine[] }> {
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Mark a booking's open invoice as paid and link the gateway payment.
*
* Called from `payment.service.finalizePaymentSuccess()` inside its existing DB
* transaction (pass the transaction's `EntityManager`). Full-payment only — no
* partial settlement in this phase. No-op when the booking has no open invoice.
*/
async markBookingInvoicePaid(
bookingId: string,
paymentId: string | null,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source: BOOKING_SOURCE,
sourceId: bookingId,
status: In([Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Overdue]),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
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;
await mg.update(
Invoice,
{ id: invoice.id },
{ status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined },
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 totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
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,
totalAmount,
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 ────────────────────────────────────────────────────────
/**
* Single settlement chokepoint for any path that marks a booking PAID: ensure
* the booking has an invoice (idempotent generate), then mark it paid. Reused by
* the gateway flow and offline/manual "mark paid" so invoicing holds everywhere.
*
* No-op for bookings that have no invoice and cannot get one (e.g. government
* bookings with no company to bill — `generateForBooking` returns null).
* Mark an invoice paid and link the gateway payment, then emit
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
* No-op when the invoice is already paid. Pass `manager` to enlist in a
* caller's transaction.
*/
async settleBookingInvoice(
bookingId: string,
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
): Promise<void> {
await this.generateForBooking(bookingId);
await this.markBookingInvoicePaid(bookingId, paymentId, manager);
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
*/
async markInvoiceAsRefunded(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Refunded,
"refunded",
{},
manager,
);
}
/**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled.
*/
async cancelInvoice(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Cancelled,
"cancelled",
{},
manager,
);
}
/**
* Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
* in the target status. Throws when the invoice does not exist.
*
* Note: the event fires in-process synchronously. When a `manager` from an
* outer transaction is passed, listeners run before that transaction commits.
*/
private async transition(
invoiceId: string,
status: Freight.InvoiceStatus,
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === status) return invoice;
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated);
return updated;
}
/** Broadcast `${invoice.source}.invoice.<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.emit(`${invoice.source}.invoice.${event}`, payload);
}
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a gateway payment should settle for a source record, or null if
* none. This is the billing document of record for "what is owed" — callers
* (e.g. `payment.service.initiatePayment`) should charge `invoice.totalAmount`
* against it rather than recomputing from the source's own total, so
* discounts/penalties/adjustments carried on the invoice are honored. Returns
* the most recent open (unpaid, non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
sourceId: string,
): Promise<Invoice | null> {
return this.dataSource.getRepository(Invoice).findOne({
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
}
/**
* Settle a source's open invoice as paid and link the gateway payment, then
* emit `${source}.invoice.paid`. Resolves the open invoice via {@link findPayable}
* then delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open invoice.
*
* Pass the caller's transaction `manager` (e.g. from
* `payment.service.finalizePaymentSuccess`) to enlist in its DB transaction.
*/
async settlePayable(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
*/
async refundPayable(
source: Freight.InvoiceSource,
sourceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
}
}
/** Round to 2 decimal places without float drift. */
function round2(n: number): number {
return Math.round(Number(n) * 100) / 100;
}