mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
feat: add pdf to the central invoice system
This commit is contained in:
@@ -14,6 +14,12 @@ import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
||||
import { applySettlement, round2 } from "./invoice-settlement.util";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
@@ -50,9 +56,6 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
@@ -122,6 +125,7 @@ export class BillingService {
|
||||
@Inject(forwardRef(() => PaymentService))
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -142,6 +146,69 @@ export class BillingService {
|
||||
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). */
|
||||
@@ -203,17 +270,8 @@ export class BillingService {
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** `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")}`;
|
||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,10 +423,11 @@ export class BillingService {
|
||||
}
|
||||
|
||||
const at = input.paidAt ?? new Date();
|
||||
const total = Number(invoice.totalAmount);
|
||||
const paidAmount = round2(Number(invoice.paidAmount) + input.amount);
|
||||
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
||||
const fullyPaid = paidAmount >= total;
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
input.amount,
|
||||
);
|
||||
const status = fullyPaid
|
||||
? Freight.InvoiceStatus.Paid
|
||||
: Freight.InvoiceStatus.PartiallyPaid;
|
||||
|
||||
Reference in New Issue
Block a user