mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat: add pdf to the central invoice system
This commit is contained in:
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
import { PortalBillingController } from "./portal-billing.controller";
|
import { PortalBillingController } from "./portal-billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
|
import { DocumentsModule } from "./documents/documents.module";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
@@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
|||||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
forwardRef(() => PaymentModule),
|
forwardRef(() => PaymentModule),
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
DocumentsModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PortalBillingController],
|
controllers: [BillingController, PortalBillingController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -194,6 +197,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -282,6 +286,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
@@ -316,6 +321,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
|||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
import { InvoiceLineRepository } from "./invoice-line.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 { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
@@ -50,9 +56,6 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
|||||||
Freight.InvoiceStatus.Overdue,
|
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. */
|
/** A single line to bill on a generated invoice. */
|
||||||
export interface InvoiceLineInput {
|
export interface InvoiceLineInput {
|
||||||
chargeType: string;
|
chargeType: string;
|
||||||
@@ -122,6 +125,7 @@ export class BillingService {
|
|||||||
@Inject(forwardRef(() => PaymentService))
|
@Inject(forwardRef(() => PaymentService))
|
||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -142,6 +146,69 @@ export class BillingService {
|
|||||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
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) ───────────────────────────────────────────
|
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||||
|
|
||||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||||
@@ -203,17 +270,8 @@ export class BillingService {
|
|||||||
// ── Generation ───────────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||||
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||||
const now = new Date();
|
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" });
|
||||||
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")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,10 +423,11 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const at = input.paidAt ?? new Date();
|
const at = input.paidAt ?? new Date();
|
||||||
const total = Number(invoice.totalAmount);
|
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||||
const paidAmount = round2(Number(invoice.paidAmount) + input.amount);
|
invoice.totalAmount,
|
||||||
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
invoice.paidAmount,
|
||||||
const fullyPaid = paidAmount >= total;
|
input.amount,
|
||||||
|
);
|
||||||
const status = fullyPaid
|
const status = fullyPaid
|
||||||
? Freight.InvoiceStatus.Paid
|
? Freight.InvoiceStatus.Paid
|
||||||
: Freight.InvoiceStatus.PartiallyPaid;
|
: Freight.InvoiceStatus.PartiallyPaid;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { InvoiceDocumentService } from "./invoice-document.service";
|
||||||
|
import { PdfRenderService } from "./pdf-render.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone document infrastructure — generic HTML→PDF plus the shared
|
||||||
|
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
|
||||||
|
* warehouses, …) can import it to print invoices without coupling to the
|
||||||
|
* billing payment graph.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [PdfRenderService, InvoiceDocumentService],
|
||||||
|
exports: [PdfRenderService, InvoiceDocumentService],
|
||||||
|
})
|
||||||
|
export class DocumentsModule {}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PdfRenderService } from "./pdf-render.service";
|
||||||
|
|
||||||
|
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||||
|
|
||||||
|
/** One billed line on the document (charge type / fee type agnostic). */
|
||||||
|
export interface InvoiceDocumentLine {
|
||||||
|
description: string | null;
|
||||||
|
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
|
||||||
|
category?: string | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
unitRate?: number | null;
|
||||||
|
amount?: number | null;
|
||||||
|
currency?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled total row in the totals box; mark `grand` for the headline total. */
|
||||||
|
export interface InvoiceDocumentTotal {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
grand?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-agnostic description of a printable invoice/receipt. Each billing
|
||||||
|
* source maps its own entity onto this shape; the renderer owns the layout so
|
||||||
|
* every EDR invoice document looks identical regardless of source.
|
||||||
|
*/
|
||||||
|
export interface InvoiceDocumentModel {
|
||||||
|
kind: InvoiceDocumentKind;
|
||||||
|
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
|
||||||
|
title: string;
|
||||||
|
documentNumber: string;
|
||||||
|
issuedAt?: Date | string | null;
|
||||||
|
status: string;
|
||||||
|
currency: string;
|
||||||
|
/** Free-form summary grid (label/value pairs). */
|
||||||
|
summary: Array<{ label: string; value: string | null }>;
|
||||||
|
/** Header for the line-item category column; column hidden when omitted. */
|
||||||
|
categoryHeader?: string;
|
||||||
|
lines: InvoiceDocumentLine[];
|
||||||
|
totals: InvoiceDocumentTotal[];
|
||||||
|
/** Override the round seal text; defaults from kind/status. */
|
||||||
|
sealText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
|
||||||
|
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
|
||||||
|
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
|
||||||
|
* `WarehouseInvoiceService`; it now serves all invoices.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class InvoiceDocumentService {
|
||||||
|
constructor(private readonly pdf: PdfRenderService) {}
|
||||||
|
|
||||||
|
async render(
|
||||||
|
model: InvoiceDocumentModel,
|
||||||
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const html = this.buildHtml(model);
|
||||||
|
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||||
|
return {
|
||||||
|
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||||
|
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHtml(model: InvoiceDocumentModel): string {
|
||||||
|
const esc = (value: unknown) =>
|
||||||
|
String(value ?? "-")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
const money = (amount: unknown, currency = model.currency) =>
|
||||||
|
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||||
|
const date = (value: unknown) =>
|
||||||
|
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||||
|
|
||||||
|
const showCategory = Boolean(model.categoryHeader);
|
||||||
|
const sealText =
|
||||||
|
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||||
|
|
||||||
|
const summaryRows = model.summary
|
||||||
|
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const itemRows = model.lines
|
||||||
|
.map(
|
||||||
|
(item) => `<tr>
|
||||||
|
<td>${esc(item.description)}</td>
|
||||||
|
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
|
||||||
|
<td class="num">${esc(item.quantity ?? 0)}</td>
|
||||||
|
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
|
||||||
|
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const totalRows = model.totals
|
||||||
|
.map(
|
||||||
|
(total) =>
|
||||||
|
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||||
|
.doc { padding: 16px 8px; position: relative; }
|
||||||
|
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||||
|
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||||
|
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||||
|
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||||
|
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||||
|
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||||
|
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||||
|
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||||
|
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||||
|
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||||
|
td.num, th.num { text-align: right; }
|
||||||
|
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||||
|
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||||
|
.grand { font-size: 16px; font-weight: 800; }
|
||||||
|
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||||
|
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
Document no.
|
||||||
|
<strong>${esc(model.documentNumber)}</strong>
|
||||||
|
Issued: ${esc(date(model.issuedAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="seal">${esc(sealText)}</div>
|
||||||
|
<div class="summary">${summaryRows}</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Description</th>
|
||||||
|
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
|
||||||
|
<th class="num">Qty</th>
|
||||||
|
<th class="num">Rate</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${itemRows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="totals">${totalRows}</div>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="line">Prepared by EDR finance</div>
|
||||||
|
<div class="line">Authorized seal / signature</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeFilename(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
|
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
const MIN_VALID_PDF_BYTES = 2_000;
|
||||||
|
|
||||||
|
const PDF_PRINT_STYLES = `
|
||||||
|
<style id="edr-pdf-print-fix">
|
||||||
|
@media print {
|
||||||
|
html, body {
|
||||||
|
background: #fff !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>`;
|
||||||
|
|
||||||
|
export interface PdfRenderOptions {
|
||||||
|
/** Label used in logs to identify the document kind. */
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||||
|
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||||
|
* header). When omitted, a generic single-page fallback is produced.
|
||||||
|
*/
|
||||||
|
fallback?: (preparedHtml: string) => Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic HTML → PDF renderer shared by every document producer (invoices,
|
||||||
|
* receipts, warehouse release orders). Renders via headless Chromium when
|
||||||
|
* available and degrades to a caller-supplied (or generic) hand-built PDF
|
||||||
|
* otherwise. This is pure infrastructure — it knows nothing about invoices.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PdfRenderService {
|
||||||
|
private readonly logger = new Logger(PdfRenderService.name);
|
||||||
|
|
||||||
|
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
|
||||||
|
const label = opts.label ?? "document";
|
||||||
|
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||||
|
const executablePath = this.resolveExecutablePath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const puppeteer = await import("puppeteer");
|
||||||
|
const launchOptions: import("puppeteer").LaunchOptions = {
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
...(executablePath ? { executablePath } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await puppeteer.default.launch(launchOptions);
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||||
|
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||||
|
await page.emulateMediaType("print");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(pdf);
|
||||||
|
if (!this.isValidPdf(buffer)) {
|
||||||
|
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
|
||||||
|
);
|
||||||
|
return buffer;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||||
|
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||||
|
if (this.isValidPdf(fallback)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private injectPdfPrintStyles(html: string): string {
|
||||||
|
if (html.includes("edr-pdf-print-fix")) return html;
|
||||||
|
if (html.includes("</head>")) {
|
||||||
|
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
|
||||||
|
}
|
||||||
|
return `${PDF_PRINT_STYLES}${html}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveExecutablePath(): string | undefined {
|
||||||
|
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||||
|
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
];
|
||||||
|
return candidates.find((path) => existsSync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidPdf(buffer: Buffer): boolean {
|
||||||
|
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
|
||||||
|
private genericFallbackPdf(html: string): Buffer {
|
||||||
|
const text = html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/[^\x20-\x7e]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 900);
|
||||||
|
|
||||||
|
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||||
|
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
|
||||||
|
const stream =
|
||||||
|
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
|
||||||
|
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
|
||||||
|
"ET";
|
||||||
|
|
||||||
|
const objects = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||||
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||||
|
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let pdf = "%PDF-1.4\n";
|
||||||
|
const offsets: number[] = [];
|
||||||
|
objects.forEach((object, index) => {
|
||||||
|
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||||
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||||
|
});
|
||||||
|
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||||
|
return Buffer.from(pdf, "latin1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Shared per-day sequential invoice numbering, used by every billing source
|
||||||
|
* (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the
|
||||||
|
* `MAX(seq)+1` allocation live in one place instead of being copy-pasted per
|
||||||
|
* service.
|
||||||
|
*
|
||||||
|
* Produces `<CODE>-YYYYMMDD-00001`: the sequence is the max existing suffix for
|
||||||
|
* the day + 1. Run inside the caller's transaction (pass that transaction's
|
||||||
|
* manager) so concurrent generation within a transaction stays consistent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
|
||||||
|
export interface SqlRunner {
|
||||||
|
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceNumberOptions {
|
||||||
|
/** Schema-qualified table to scan, e.g. `freight.invoices`. */
|
||||||
|
table: string;
|
||||||
|
/** Document code prefix, e.g. `FRT` or `WHF`. */
|
||||||
|
code: string;
|
||||||
|
/** Column holding the number; defaults to `invoice_number`. */
|
||||||
|
column?: string;
|
||||||
|
/** Clock injection point (tests); defaults to now. */
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nextDailyInvoiceNumber(
|
||||||
|
runner: SqlRunner,
|
||||||
|
opts: InvoiceNumberOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const now = opts.now ?? new Date();
|
||||||
|
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const prefix = `${opts.code}-${ymd}-`;
|
||||||
|
const column = opts.column ?? "invoice_number";
|
||||||
|
|
||||||
|
const [row] = await runner.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
|
||||||
|
FROM ${opts.table} WHERE ${column} LIKE $1`,
|
||||||
|
[`${prefix}%`],
|
||||||
|
);
|
||||||
|
const next = Number(row?.seq ?? 0) + 1;
|
||||||
|
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Shared payment/settlement math for invoices. Both the global
|
||||||
|
* `BillingService.recordPayment` and the warehouse fee invoice flow apply a
|
||||||
|
* payment the same way — accumulate `paidAmount`, derive the outstanding
|
||||||
|
* `balanceAmount`, and decide whether the invoice is now fully settled. Keeping
|
||||||
|
* it here means the two flows can never drift on rounding or the
|
||||||
|
* partial-vs-full threshold.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Round to 2 decimals, avoiding binary float drift. */
|
||||||
|
export const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
export interface SettlementResult {
|
||||||
|
/** New cumulative amount paid. */
|
||||||
|
paidAmount: number;
|
||||||
|
/** Remaining balance (0 once fully paid). */
|
||||||
|
balanceAmount: number;
|
||||||
|
/** True once the balance reaches zero. */
|
||||||
|
fullyPaid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a single payment of `amount` to an invoice with `totalAmount` already
|
||||||
|
* carrying `currentPaid`. Caller is responsible for validating `amount > 0` and
|
||||||
|
* the invoice being in a payable state.
|
||||||
|
*/
|
||||||
|
export function applySettlement(
|
||||||
|
totalAmount: number,
|
||||||
|
currentPaid: number,
|
||||||
|
amount: number,
|
||||||
|
): SettlementResult {
|
||||||
|
const total = Number(totalAmount);
|
||||||
|
const paidAmount = round2(Number(currentPaid) + Number(amount));
|
||||||
|
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
||||||
|
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvoiceDocumentModel,
|
||||||
|
InvoiceDocumentService,
|
||||||
|
} from '../billing/documents/invoice-document.service';
|
||||||
|
import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util';
|
||||||
|
import { applySettlement } from '../billing/invoice-settlement.util';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import {
|
import {
|
||||||
WarehouseFeeInvoice,
|
WarehouseFeeInvoice,
|
||||||
@@ -11,7 +17,6 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
|
|||||||
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||||
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
|
||||||
|
|
||||||
interface GenerateOptions {
|
interface GenerateOptions {
|
||||||
confirmZero?: boolean;
|
confirmZero?: boolean;
|
||||||
@@ -56,7 +61,7 @@ export class WarehouseInvoiceService {
|
|||||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||||
private readonly feeService: WarehouseFeeService,
|
private readonly feeService: WarehouseFeeService,
|
||||||
private readonly documents: WarehouseReleaseDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -161,18 +166,12 @@ export class WarehouseInvoiceService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
/** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */
|
||||||
private async nextInvoiceNumber(): Promise<string> {
|
private nextInvoiceNumber(): Promise<string> {
|
||||||
const now = new Date();
|
return nextDailyInvoiceNumber(this.dataSource, {
|
||||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
table: 'freight.warehouse_fee_invoices',
|
||||||
const prefix = `WHF-${ymd}-`;
|
code: 'WHF',
|
||||||
const [row] = await this.dataSource.query(
|
});
|
||||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
|
||||||
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
|
|
||||||
[`${prefix}%`],
|
|
||||||
);
|
|
||||||
const next = Number(row?.seq ?? 0) + 1;
|
|
||||||
return `${prefix}${String(next).padStart(5, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reads ────────────────────────────────────────────────────────────────
|
// ── Reads ────────────────────────────────────────────────────────────────
|
||||||
@@ -186,12 +185,7 @@ export class WarehouseInvoiceService {
|
|||||||
|
|
||||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||||
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
|
|
||||||
return {
|
|
||||||
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
|
||||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
@@ -199,11 +193,69 @@ export class WarehouseInvoiceService {
|
|||||||
if (Number(invoice.paidAmount) <= 0) {
|
if (Number(invoice.paidAmount) <= 0) {
|
||||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||||
}
|
}
|
||||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||||
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
|
}
|
||||||
|
|
||||||
|
/** Map a warehouse fee invoice (with display details + items) onto the shared document model. */
|
||||||
|
private toDocumentModel(
|
||||||
|
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||||
|
kind: 'INVOICE' | 'RECEIPT',
|
||||||
|
): InvoiceDocumentModel {
|
||||||
|
const items = invoice.items as Array<{
|
||||||
|
description?: string;
|
||||||
|
feeType?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unitRate?: number;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
chargeableDays?: number | null;
|
||||||
|
}>;
|
||||||
|
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||||
|
const date = (value: unknown) =>
|
||||||
|
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
kind,
|
||||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
title: 'Warehouse Fee',
|
||||||
|
documentNumber: invoice.invoiceNumber,
|
||||||
|
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||||
|
status: invoice.status,
|
||||||
|
currency: invoice.currency,
|
||||||
|
summary: [
|
||||||
|
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
|
||||||
|
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
|
||||||
|
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
|
||||||
|
{ label: 'Customer', value: invoice.customerName ?? null },
|
||||||
|
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
|
||||||
|
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
|
||||||
|
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
|
||||||
|
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
|
||||||
|
{
|
||||||
|
label: 'Yard / Zone',
|
||||||
|
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
|
||||||
|
},
|
||||||
|
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
|
||||||
|
{
|
||||||
|
label: 'Payment',
|
||||||
|
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
categoryHeader: 'Fee type',
|
||||||
|
lines: items.map((item) => ({
|
||||||
|
description: item.description ?? null,
|
||||||
|
category: item.feeType ?? null,
|
||||||
|
quantity: item.quantity ?? item.chargeableDays ?? 0,
|
||||||
|
unitRate: item.unitRate,
|
||||||
|
amount: item.amount,
|
||||||
|
currency: item.currency ?? invoice.currency,
|
||||||
|
})),
|
||||||
|
totals: [
|
||||||
|
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
|
||||||
|
{ label: 'Tax', amount: Number(invoice.taxAmount) },
|
||||||
|
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
|
||||||
|
{ label: 'Paid', amount: Number(invoice.paidAmount) },
|
||||||
|
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +289,11 @@ export class WarehouseInvoiceService {
|
|||||||
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
||||||
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
||||||
|
|
||||||
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||||
const total = Number(invoice.totalAmount);
|
invoice.totalAmount,
|
||||||
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
invoice.paidAmount,
|
||||||
const fullyPaid = paidAmount >= total;
|
dto.amount,
|
||||||
|
);
|
||||||
|
|
||||||
const payments = [
|
const payments = [
|
||||||
...(invoice.payments ?? []),
|
...(invoice.payments ?? []),
|
||||||
@@ -248,8 +301,8 @@ export class WarehouseInvoiceService {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const updated = await this.invoiceRepository.update(id, {
|
const updated = await this.invoiceRepository.update(id, {
|
||||||
paidAmount: Math.round(paidAmount * 100) / 100,
|
paidAmount,
|
||||||
balanceAmount: balance,
|
balanceAmount,
|
||||||
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
||||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||||
payments,
|
payments,
|
||||||
@@ -460,131 +513,4 @@ export class WarehouseInvoiceService {
|
|||||||
|
|
||||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildInvoiceDocumentHtml(
|
|
||||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
|
||||||
kind: 'INVOICE' | 'RECEIPT',
|
|
||||||
details: InvoiceDocumentDetails,
|
|
||||||
): string {
|
|
||||||
const esc = (value: unknown) =>
|
|
||||||
String(value ?? '-')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
const money = (amount: unknown, currency = invoice.currency) =>
|
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
|
||||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
|
||||||
const items = invoice.items as Array<{
|
|
||||||
id?: string;
|
|
||||||
description?: string;
|
|
||||||
feeType?: string;
|
|
||||||
quantity?: number;
|
|
||||||
unitRate?: number;
|
|
||||||
amount?: number;
|
|
||||||
currency?: string;
|
|
||||||
chargeableDays?: number | null;
|
|
||||||
}>;
|
|
||||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
|
||||||
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
|
||||||
.doc { padding: 16px 8px; position: relative; }
|
|
||||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
|
||||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
|
||||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
|
||||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
|
||||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
|
||||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
|
||||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
|
||||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
|
||||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
|
||||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
|
||||||
td.num, th.num { text-align: right; }
|
|
||||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
|
||||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
|
||||||
.grand { font-size: 16px; font-weight: 800; }
|
|
||||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
|
||||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="doc">
|
|
||||||
<div class="top">
|
|
||||||
<div>
|
|
||||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
|
||||||
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
Document no.
|
|
||||||
<strong>${esc(invoice.invoiceNumber)}</strong>
|
|
||||||
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="seal">${esc(sealText)}</div>
|
|
||||||
<div class="summary">
|
|
||||||
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
|
|
||||||
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
|
|
||||||
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
|
|
||||||
<div><span>Customer</span>${esc(details.customerName)}</div>
|
|
||||||
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
|
|
||||||
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
|
|
||||||
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
|
|
||||||
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
|
|
||||||
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
|
|
||||||
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
|
|
||||||
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Fee type</th>
|
|
||||||
<th class="num">Qty</th>
|
|
||||||
<th class="num">Rate</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${items
|
|
||||||
.map(
|
|
||||||
(item) => `<tr>
|
|
||||||
<td>${esc(item.description)}</td>
|
|
||||||
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
|
|
||||||
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
|
|
||||||
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
|
|
||||||
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
|
|
||||||
</tr>`,
|
|
||||||
)
|
|
||||||
.join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="totals">
|
|
||||||
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
|
|
||||||
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
|
|
||||||
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
|
|
||||||
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
|
|
||||||
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="line">Prepared by EDR warehouse finance</div>
|
|
||||||
<div class="line">Authorized seal / signature</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private safeFilename(value: string): string {
|
|
||||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,23 @@
|
|||||||
import { existsSync } from 'fs';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
|
||||||
const MIN_VALID_PDF_BYTES = 2_000;
|
const MIN_VALID_PDF_BYTES = 2_000;
|
||||||
|
|
||||||
const RELEASE_DOCUMENT_PRINT_STYLES = `
|
|
||||||
<style id="warehouse-release-document-print-fix">
|
|
||||||
@media print {
|
|
||||||
html, body {
|
|
||||||
background: #fff !important;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>`;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseReleaseDocumentService {
|
export class WarehouseReleaseDocumentService {
|
||||||
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
|
constructor(private readonly pdf: PdfRenderService) {}
|
||||||
|
|
||||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
/**
|
||||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
* Render the gate-clearance release document to PDF via the shared renderer,
|
||||||
const executablePath = this.resolveExecutablePath();
|
* falling back to the release-specific hand-built layout when Chromium is
|
||||||
|
* unavailable.
|
||||||
try {
|
*/
|
||||||
const puppeteer = await import('puppeteer');
|
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
return this.pdf.htmlToPdfBuffer(html, {
|
||||||
headless: true,
|
label: 'Warehouse release',
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
||||||
...(executablePath ? { executablePath } : {}),
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const browser = await puppeteer.default.launch(launchOptions);
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
|
||||||
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
|
|
||||||
await page.emulateMediaType('print');
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
|
||||||
format: 'A4',
|
|
||||||
printBackground: true,
|
|
||||||
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const buffer = Buffer.from(pdf);
|
|
||||||
if (!this.isValidPdf(buffer)) {
|
|
||||||
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
|
|
||||||
}
|
|
||||||
this.logger.log(
|
|
||||||
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
|
||||||
);
|
|
||||||
return buffer;
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
|
|
||||||
);
|
|
||||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
|
||||||
if (this.isValidPdf(fallback)) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
|
||||||
);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
throw new InternalServerErrorException(
|
|
||||||
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private injectPdfPrintStyles(html: string): string {
|
|
||||||
if (html.includes('warehouse-release-document-print-fix')) return html;
|
|
||||||
if (html.includes('</head>')) {
|
|
||||||
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
|
|
||||||
}
|
|
||||||
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveExecutablePath(): string | undefined {
|
|
||||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
|
||||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
'/usr/bin/chromium',
|
|
||||||
'/usr/bin/chromium-browser',
|
|
||||||
'/usr/bin/google-chrome-stable',
|
|
||||||
'/usr/bin/google-chrome',
|
|
||||||
];
|
|
||||||
return candidates.find((path) => existsSync(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidPdf(buffer: Buffer): boolean {
|
|
||||||
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
@@ -70,6 +71,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseFeeInvoice,
|
WarehouseFeeInvoice,
|
||||||
WarehouseFeeInvoiceItem,
|
WarehouseFeeInvoiceItem,
|
||||||
]),
|
]),
|
||||||
|
DocumentsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
forwardRef(() => LastMileModule),
|
forwardRef(() => LastMileModule),
|
||||||
|
|||||||
Reference in New Issue
Block a user