chore: updating billing logic

This commit is contained in:
Nathnael
2026-06-27 08:42:18 +00:00
parent 1e27b96708
commit 55c42058d0
3 changed files with 475 additions and 9 deletions

View File

@@ -1,26 +1,333 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
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[] = [
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 {
chargeType: string;
description: string;
quantity: number;
unitRate: number;
amount: number;
currency: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
constructor(
@InjectRepository(Invoice)
private readonly invoicesRepository: Repository<Invoice>,
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
) {}
// ── Reads ──────────────────────────────────────────────────────────────────
/** List every invoice (most recent first). */
findAll(): Promise<Invoice[]> {
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoicesRepository.find({
where: { bookingId },
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);
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[] };
}
// ── 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();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}
// ── Payment reconciliation ───────────────────────────────────────────────────
/**
* The invoice a gateway payment should settle for a booking, 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 `booking.totalAmount`, so discounts/penalties/
* adjustments carried on the invoice are honored. Returns the most recent open
* (unpaid, non-cancelled) invoice.
*/
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,
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 },
);
}
/**
* 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;
await mg.update(
Invoice,
{ id: invoice.id },
{ status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined },
);
}
/**
* 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).
*/
async settleBookingInvoice(
bookingId: string,
paymentId: string | null = null,
manager?: EntityManager,
): Promise<void> {
await this.generateForBooking(bookingId);
await this.markBookingInvoicePaid(bookingId, paymentId, manager);
}
}
/** Round to 2 decimal places without float drift. */
function round2(n: number): number {
return Math.round(Number(n) * 100) / 100;
}