@velaro/cli 1.4.53 → 1.4.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,534 @@
1
+ // Shared PDF generator for BillingSubscriptionInvoice documents (invoice / receipt).
2
+ //
3
+ // Structured as a plain module with no CLI/yargs dependency so a future server-side endpoint
4
+ // (e.g. a `GET /api/BillingSubscription/invoice/{id}/pdf` action, mirroring the existing
5
+ // BillingInvoiceRecordController.GetPdfAsync pattern) can call `generateBillingDocument()`
6
+ // directly instead of re-implementing layout. The CLI command
7
+ // (`velaro billing-subscription document`, see ../commands/billing-subscription.js) is the only
8
+ // current caller.
9
+ //
10
+ // Known layout bugs this module exists to fix (see task history — a hand-built fpdf2 receipt
11
+ // shipped with a too-small logo and a totals block whose amounts ran off the right edge of the
12
+ // page):
13
+ // 1. Logo is sized to a fixed, legible mm width (not "whatever fits"), see LOGO_WIDTH_PT below.
14
+ // 2. The totals block's rendered width is measured against the ACTUAL fonts/sizes used, using
15
+ // the real pdfkit doc instance, and is right-aligned against the true printable right
16
+ // margin — never an eyeballed offset. See computeTotalsLayout().
17
+ //
18
+ // Data contract: `invoice`/`subscription`/`payments`/`balance` are the exact camelCase JSON shapes
19
+ // returned by GET /api/BillingSubscription/invoice/{invoiceId} and
20
+ // GET /api/BillingSubscription/invoice/{invoiceId}/payments — see cli/lib/commands/billing-subscription.js
21
+ // for the real request() calls. This module never talks to the network itself.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import PDFDocument from 'pdfkit';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ export const LOGO_PATH = path.join(__dirname, '..', '..', 'assets', 'velaro-logo-lockup-full-color.png');
30
+
31
+ // Decorative background patterns — a small, FIXED, checked-in set (never generated per-invoice;
32
+ // that would be wasteful and would make PDF generation depend on a live AI endpoint). Generated
33
+ // once via Velaro Studio's image endpoint (POST /api/assist/generate-image), same mechanism
34
+ // documented in the `generate-brand-image` skill. Each is a subtle, low-opacity, brand-blue
35
+ // abstract pattern on a near-white base — see cli/assets/billing-doc-patterns/README (none of
36
+ // these differ meaningfully enough to need per-file docs beyond the filenames themselves).
37
+ export const PATTERN_DIR = path.join(__dirname, '..', '..', 'assets', 'billing-doc-patterns');
38
+ export const PATTERN_FILES = [
39
+ 'wave-lines.png',
40
+ 'dot-grid.png',
41
+ 'concentric-arcs.png',
42
+ 'diagonal-hatch.png',
43
+ 'hex-grid.png',
44
+ ];
45
+ // Composited at very low opacity full-bleed behind everything else on the page — the opaque
46
+ // header/nav bars, alternating row bands, and status band already drawn on top of it act as the
47
+ // scrim for the line-item/totals text; this constant keeps the pattern itself faint enough that
48
+ // even the unshielded areas (page margins, non-alternating table rows) never threaten contrast.
49
+ export const PATTERN_OPACITY = 0.14;
50
+
51
+ /**
52
+ * Deterministically picks a pattern index for a given invoice identifier: the same invoice
53
+ * number always resolves to the same pattern (stable across re-renders/reprints), while
54
+ * different invoice numbers rotate through the full pattern set — same principle as
55
+ * velaro-marketing/admin/linkedin-engine.js's pickMotif(), not the same implementation.
56
+ */
57
+ export function pickPatternIndex(seed) {
58
+ const str = String(seed ?? '');
59
+ let hash = 0;
60
+ for (let i = 0; i < str.length; i++) {
61
+ hash = (hash * 31 + str.charCodeAt(i)) >>> 0; // unsigned 32-bit, avoids negative mod
62
+ }
63
+ return hash % PATTERN_FILES.length;
64
+ }
65
+
66
+ export function pickPatternFile(seed) {
67
+ return PATTERN_FILES[pickPatternIndex(seed)];
68
+ }
69
+
70
+ const patternBufferCache = new Map();
71
+ function loadPatternBuffer(filename) {
72
+ if (patternBufferCache.has(filename)) return patternBufferCache.get(filename);
73
+ const buffer = fs.readFileSync(path.join(PATTERN_DIR, filename));
74
+ patternBufferCache.set(filename, buffer);
75
+ return buffer;
76
+ }
77
+
78
+ // Velaro brand palette (velaro-marketing/CLAUDE.md "COLOR CONTRAST" rule mirrored here — this is
79
+ // a customer-facing document, same standard applies): dark text on light backgrounds is never
80
+ // lighter than #374151, white text on dark/vivid bands only.
81
+ export const COLOR = {
82
+ primaryBlue: '#1670E7',
83
+ darkNavy: '#0d1535',
84
+ darkText: '#111111',
85
+ mutedText: '#374151',
86
+ white: '#ffffff',
87
+ rule: '#e2e8f0',
88
+ rowAlt: '#f8fafc',
89
+ successGreen: '#2FBF71',
90
+ amberDue: '#c98f00', // text-safe amber per brand palette (not the raw #FFBD22 swatch)
91
+ };
92
+
93
+ // Bank remittance details, KV-first per this repo's secrets rule -- never hardcoded here.
94
+ // Set 2026-09-15 (real values confirmed by Alex from an existing customer invoice):
95
+ // az keyvault secret set --vault-name velaro-eastus-prd-kv --name Billing<Name> --value <value>
96
+ const BANK_INFO_VAULT = 'velaro-eastus-prd-kv';
97
+ const BANK_INFO_SECRETS = {
98
+ accountNumber: 'BillingBankAccountNumber',
99
+ achRouting: 'BillingAchRoutingNumber',
100
+ wireRouting: 'BillingWireRoutingNumber',
101
+ swift: 'BillingSwiftCode',
102
+ };
103
+ let bankInfoCache = null;
104
+ async function getBankingInfo() {
105
+ if (bankInfoCache) return bankInfoCache;
106
+ try {
107
+ const { SecretClient } = await import('@azure/keyvault-secrets');
108
+ const { DefaultAzureCredential } = await import('@azure/identity');
109
+ const client = new SecretClient(`https://${BANK_INFO_VAULT}.vault.azure.net`, new DefaultAzureCredential());
110
+ const entries = await Promise.all(
111
+ Object.entries(BANK_INFO_SECRETS).map(async ([key, secretName]) => {
112
+ const secret = await client.getSecret(secretName);
113
+ return [key, secret.value];
114
+ })
115
+ );
116
+ bankInfoCache = Object.fromEntries(entries);
117
+ } catch (err) {
118
+ // Non-fatal: an invoice with no banking line is far better than one that refuses to
119
+ // generate because Key Vault was unreachable. Logged loudly so it's never silently missing.
120
+ console.error(`[WARN] Could not load banking info from Key Vault (${BANK_INFO_VAULT}): ${err.message}. Invoice will omit the ACH/wire remittance line.`);
121
+ bankInfoCache = null;
122
+ }
123
+ return bankInfoCache;
124
+ }
125
+
126
+ const MM_TO_PT = 2.8346456692913;
127
+ export const PAGE_MARGIN = 50; // pt, ~17.6mm
128
+ export const LOGO_WIDTH_PT = 35 * MM_TO_PT; // ~99pt -- 42mm (2026-09-15) was a bit too big per Alex, split the difference
129
+
130
+ export function money(cents) {
131
+ const value = (Number(cents) || 0) / 100;
132
+ return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
133
+ }
134
+
135
+ export function formatDate(input) {
136
+ if (!input) return null;
137
+ const date = new Date(input);
138
+ if (Number.isNaN(date.getTime())) return null;
139
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
140
+ }
141
+
142
+ /**
143
+ * Computes {labelColWidth, valueColWidth, columnGap, totalWidth, valueFontSize} for a two-column
144
+ * totals block (label | value, one row each) and asserts the block fits within
145
+ * `printableWidth` — measured against the SAME `doc` instance the document is being rendered
146
+ * into, so this reflects the real fonts/sizes in play, never a guessed value.
147
+ *
148
+ * Tries progressively smaller value font sizes (largest first) before giving up. Throws a
149
+ * descriptive Error — never returns a layout wider than `printableWidth`.
150
+ */
151
+ export function computeTotalsLayout(doc, rows, printableWidth, opts = {}) {
152
+ if (!rows || rows.length === 0) throw new Error('computeTotalsLayout: rows must be non-empty.');
153
+
154
+ const labelFont = opts.labelFont ?? 'Helvetica';
155
+ const valueFont = opts.valueFont ?? 'Helvetica-Bold';
156
+ const labelFontSize = opts.labelFontSize ?? 10;
157
+ const columnGap = opts.columnGap ?? 18;
158
+ const fontSizesToTry = opts.fontSizesToTry ?? [opts.valueFontSize ?? 13, 12, 11, 10, 9];
159
+
160
+ doc.font(labelFont).fontSize(labelFontSize);
161
+ const labelColWidth = Math.max(...rows.map(r => doc.widthOfString(r.label)));
162
+
163
+ let last = null;
164
+ for (const valueFontSize of fontSizesToTry) {
165
+ doc.font(valueFont).fontSize(valueFontSize);
166
+ const valueColWidth = Math.max(...rows.map(r => doc.widthOfString(r.value)));
167
+ const totalWidth = labelColWidth + columnGap + valueColWidth;
168
+ last = { labelColWidth, valueColWidth, columnGap, totalWidth, valueFontSize, labelFontSize };
169
+ if (totalWidth <= printableWidth) return last;
170
+ }
171
+
172
+ throw new Error(
173
+ `Totals block would overflow the printable width (${printableWidth.toFixed(1)}pt) even at the ` +
174
+ `smallest tried value font size (${last.valueFontSize}pt): the block needs ${last.totalWidth.toFixed(1)}pt. ` +
175
+ `Rows: ${rows.map(r => `${r.label}=${r.value}`).join(', ')}. ` +
176
+ `Refusing to draw a totals block that would run off the page.`
177
+ );
178
+ }
179
+
180
+ function drawTotalsBlock(doc, rows, { rightEdge, leftEdge, y, rowGap = 6 }) {
181
+ const printableWidth = rightEdge - leftEdge;
182
+ const layout = computeTotalsLayout(doc, rows, printableWidth);
183
+ const blockLeftX = rightEdge - layout.totalWidth;
184
+
185
+ // Defensive: computeTotalsLayout already guarantees totalWidth <= printableWidth, but assert
186
+ // the resulting left edge is never pushed past the page's own left margin either.
187
+ if (blockLeftX < leftEdge) {
188
+ throw new Error(
189
+ `Totals block left edge (${blockLeftX.toFixed(1)}pt) would fall left of the printable area ` +
190
+ `(${leftEdge}pt) -- this should be impossible given computeTotalsLayout's own assertion.`
191
+ );
192
+ }
193
+
194
+ let cursorY = y;
195
+ for (const row of rows) {
196
+ const rowHeight = Math.max(layout.labelFontSize, layout.valueFontSize) + 4;
197
+
198
+ doc.font('Helvetica').fontSize(layout.labelFontSize).fillColor(row.labelColor ?? COLOR.mutedText);
199
+ doc.text(row.label, blockLeftX, cursorY + (rowHeight - layout.labelFontSize) / 2, {
200
+ width: layout.labelColWidth,
201
+ align: 'left',
202
+ });
203
+
204
+ doc.font('Helvetica-Bold').fontSize(layout.valueFontSize).fillColor(row.valueColor ?? COLOR.darkText);
205
+ doc.text(row.value, rightEdge - layout.valueColWidth, cursorY, {
206
+ width: layout.valueColWidth,
207
+ align: 'right',
208
+ });
209
+
210
+ if (row.rule) {
211
+ doc.moveTo(blockLeftX, cursorY - rowGap / 2)
212
+ .lineTo(rightEdge, cursorY - rowGap / 2)
213
+ .lineWidth(0.75)
214
+ .strokeColor(COLOR.rule)
215
+ .stroke();
216
+ }
217
+
218
+ cursorY += rowHeight + rowGap;
219
+ }
220
+
221
+ return cursorY;
222
+ }
223
+
224
+ /**
225
+ * Draws the chosen decorative pattern full-bleed behind everything else on the CURRENT page, at
226
+ * PATTERN_OPACITY. Must be called before any other drawing on that page so the opaque bands drawn
227
+ * afterward (header rule, dates band, line-item table header/alt rows, status band) sit on top of
228
+ * it and act as its scrim over text-bearing areas.
229
+ */
230
+ function drawBackgroundPattern(doc, patternBuffer, pageWidth, pageHeight) {
231
+ doc.save();
232
+ doc.opacity(PATTERN_OPACITY);
233
+ // `cover` (not width/height) -- the source patterns are square (1024x1024) but the page is not
234
+ // (612x792pt LETTER); plain width/height would stretch x and y independently (~29% differential
235
+ // distortion), turning e.g. concentric-arcs.png's circles into ellipses. `cover` scales
236
+ // uniformly and crops to fill instead, so the pattern always renders at its true aspect ratio.
237
+ doc.image(patternBuffer, 0, 0, { cover: [pageWidth, pageHeight] });
238
+ doc.restore(); // restores opacity to 1 for every subsequent draw call on this page
239
+ }
240
+
241
+ function loadLogoBuffer() {
242
+ try {
243
+ return fs.readFileSync(LOGO_PATH);
244
+ } catch (err) {
245
+ // Logo is a checked-in asset (cli/assets/) — a missing file is a packaging bug, not a
246
+ // reasonable runtime condition to silently degrade past for a customer-facing document.
247
+ throw new Error(`Could not load Velaro logo asset at ${LOGO_PATH}: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Reads width/height straight out of a PNG's IHDR chunk (bytes 16-23 of any valid PNG) rather
253
+ * than hardcoding the current logo asset's pixel ratio -- if the checked-in asset is ever swapped
254
+ * for a differently-proportioned export, the header text/logo layout stays correct automatically
255
+ * instead of silently drifting out of sync with a stale constant.
256
+ */
257
+ function readPngDimensions(buffer) {
258
+ if (buffer.length < 24 || buffer.toString('ascii', 1, 4) !== 'PNG') {
259
+ throw new Error('Logo asset does not look like a valid PNG (missing PNG signature) -- cannot compute its aspect ratio.');
260
+ }
261
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
262
+ }
263
+
264
+ function paymentSummary(payments) {
265
+ const applied = (payments || []).filter(p => (p.status ?? 'applied') === 'applied');
266
+ if (applied.length === 0) return { date: null, method: null };
267
+ const latest = applied.reduce((a, b) => (new Date(a.paymentDate) > new Date(b.paymentDate) ? a : b));
268
+ const methods = new Set(applied.map(p => p.method));
269
+ const method = methods.size === 1 ? [...methods][0] : 'multiple methods';
270
+ return { date: latest.paymentDate, method };
271
+ }
272
+
273
+ /**
274
+ * Generates a branded invoice or receipt PDF for a single BillingSubscriptionInvoice.
275
+ *
276
+ * @param {object} params
277
+ * @param {object} params.invoice - camelCase JSON from GET /api/BillingSubscription/invoice/{id}
278
+ * @param {object} [params.subscription] - camelCase JSON subscription snapshot from the same endpoint
279
+ * @param {object[]} [params.payments] - rows from GET .../invoice/{id}/payments (`.payments`)
280
+ * @param {object} [params.balance] - `.balance` from the same payments endpoint
281
+ * @param {'invoice'|'receipt'|'quote'} params.type
282
+ * @param {string} [params.outPath] - if provided, the PDF is written here; the Buffer is still returned
283
+ * @returns {Promise<Buffer>}
284
+ */
285
+ export async function generateBillingDocument({ invoice, subscription, payments = [], balance, type, outPath }) {
286
+ if (!invoice) throw new Error('generateBillingDocument: invoice is required.');
287
+ if (type !== 'invoice' && type !== 'receipt' && type !== 'quote') {
288
+ throw new Error(`generateBillingDocument: type must be "invoice", "receipt", or "quote", got "${type}".`);
289
+ }
290
+
291
+ // The receipt-type guard: never generate payment-thank-you framing for an invoice that isn't
292
+ // actually paid in full. This is the ONLY gate for --type receipt and it fails closed --
293
+ // missing/unknown balance data refuses just as a partial payment does.
294
+ if (type === 'receipt' && !(balance && balance.paidInFull === true)) {
295
+ const knownState = balance ? `paidInFull=${balance.paidInFull}, remaining=${money(balance.remainingCents ?? 0)}` : 'no payment/balance data available';
296
+ throw new Error(
297
+ `Refusing to generate a receipt for invoice ${invoice.invoiceNumberFormatted ?? invoice.id}: ` +
298
+ `it is not recorded as paid in full (${knownState}). Generate --type invoice instead, or record ` +
299
+ `the remaining payment first.`
300
+ );
301
+ }
302
+
303
+ // Only an unpaid invoice needs remittance instructions -- a receipt (already paid) or a
304
+ // quote (not yet a real bill) has no reason to show them.
305
+ const balanceDueForBanking = type === 'invoice' ? (balance?.remainingCents ?? invoice.totalCents ?? 0) : 0;
306
+ const bankInfo = balanceDueForBanking > 0 ? await getBankingInfo() : null;
307
+
308
+ const logoBuffer = loadLogoBuffer();
309
+ const patternFile = pickPatternFile(invoice.invoiceNumberFormatted ?? invoice.invoiceNumber ?? invoice.id);
310
+ const patternBuffer = loadPatternBuffer(patternFile);
311
+ const doc = new PDFDocument({ size: 'LETTER', margin: PAGE_MARGIN, bufferPages: true });
312
+
313
+ const chunks = [];
314
+ doc.on('data', (c) => chunks.push(c));
315
+ const done = new Promise((resolve, reject) => {
316
+ doc.on('end', resolve);
317
+ doc.on('error', reject);
318
+ });
319
+
320
+ const pageWidth = doc.page.width;
321
+ const pageHeight = doc.page.height;
322
+ const leftEdge = PAGE_MARGIN;
323
+ const rightEdge = pageWidth - PAGE_MARGIN;
324
+ const printableWidth = rightEdge - leftEdge;
325
+
326
+ const isReceipt = type === 'receipt';
327
+ const isQuote = type === 'quote';
328
+ const title = isReceipt ? 'RECEIPT' : isQuote ? 'QUOTE' : 'INVOICE';
329
+ const customerName = invoice.customerName || subscription?.customerName || '(customer name unavailable)';
330
+
331
+ // Reserve room at the bottom of every page for the footer band so nothing drawn above it can
332
+ // ever run into (or past) it. Any block that might grow (line items, totals, status band) must
333
+ // check ensureSpace() before drawing and flow onto a fresh page rather than silently overflow.
334
+ const FOOTER_RESERVE = 40;
335
+ const bottomLimit = pageHeight - PAGE_MARGIN - FOOTER_RESERVE;
336
+ function ensureSpace(requiredHeight) {
337
+ if (y + requiredHeight > bottomLimit) {
338
+ doc.addPage();
339
+ y = PAGE_MARGIN;
340
+ drawBackgroundPattern(doc, patternBuffer, pageWidth, pageHeight);
341
+ }
342
+ }
343
+
344
+ // Draw the background pattern first so every subsequent draw call (header, bands, table,
345
+ // totals, status, footer) lands on top of it.
346
+ drawBackgroundPattern(doc, patternBuffer, pageWidth, pageHeight);
347
+
348
+ // ── Header: logo top-left, company identity clear of it, title top-right ──
349
+ const logoDimensions = readPngDimensions(logoBuffer);
350
+ const logoHeight = LOGO_WIDTH_PT * (logoDimensions.height / logoDimensions.width); // derived from the actual loaded asset, not a hardcoded ratio
351
+ doc.image(logoBuffer, leftEdge, PAGE_MARGIN, { width: LOGO_WIDTH_PT });
352
+
353
+ const companyInfoX = leftEdge; // below the logo, not beside it -- avoids any overlap regardless of title width
354
+ const companyInfoY = PAGE_MARGIN + logoHeight + 8;
355
+ doc.font('Helvetica-Bold').fontSize(12).fillColor(COLOR.darkText)
356
+ .text('Velaro Incorporated', companyInfoX, companyInfoY);
357
+ doc.font('Helvetica').fontSize(9).fillColor(COLOR.mutedText)
358
+ .text('billing@velaro.com', companyInfoX, companyInfoY + 15);
359
+
360
+ doc.font('Helvetica-Bold').fontSize(22).fillColor(COLOR.primaryBlue)
361
+ .text(title, leftEdge, PAGE_MARGIN, { width: printableWidth, align: 'right' });
362
+ // A quote is not a bill -- never surface the real INV- invoice number under a QUOTE title, since
363
+ // that number is a live BillingSubscriptionInvoice id a customer could reference as if it were
364
+ // an actual invoice. Render a distinct QUO- reference derived from the same id instead.
365
+ const displayedRef = isQuote
366
+ ? `QUO-${invoice.invoiceNumber ?? invoice.id}`
367
+ : (invoice.invoiceNumberFormatted ?? `#${invoice.id}`);
368
+ doc.font('Helvetica').fontSize(11).fillColor(COLOR.mutedText)
369
+ .text(displayedRef, leftEdge, PAGE_MARGIN + 28, { width: printableWidth, align: 'right' });
370
+ if (invoice.poNumber) {
371
+ doc.font('Helvetica').fontSize(9).fillColor(COLOR.mutedText)
372
+ .text(`PO: ${invoice.poNumber}`, leftEdge, PAGE_MARGIN + 43, { width: printableWidth, align: 'right' });
373
+ }
374
+
375
+ const headerBottomY = Math.max(companyInfoY + 26, PAGE_MARGIN + 56);
376
+ doc.moveTo(leftEdge, headerBottomY).lineTo(rightEdge, headerBottomY)
377
+ .lineWidth(2).strokeColor(COLOR.darkNavy).stroke();
378
+
379
+ // ── Bill To / dates band ──
380
+ let y = headerBottomY + 16;
381
+ const bandHeight = 64;
382
+ doc.rect(leftEdge, y, printableWidth, bandHeight).fill(COLOR.rowAlt);
383
+
384
+ const datesX = leftEdge + printableWidth / 2;
385
+
386
+ doc.font('Helvetica').fontSize(9).fillColor(COLOR.mutedText)
387
+ .text('BILL TO', leftEdge + 12, y + 9);
388
+ doc.font('Helvetica-Bold').fontSize(12).fillColor(COLOR.darkText)
389
+ .text(customerName, leftEdge + 12, y + 22, { width: datesX - (leftEdge + 12) - 10 }); // bounded so a long customer name wraps instead of overlapping the dates column
390
+ const receiptPaymentSummary = isReceipt ? paymentSummary(payments) : null;
391
+ const dateRows = isQuote
392
+ ? [
393
+ ['Quote Date', formatDate(invoice.invoiceDate)],
394
+ ['Service Period', `${formatDate(invoice.servicePeriodStart) ?? '?'} - ${formatDate(invoice.servicePeriodEnd) ?? '?'}`],
395
+ ]
396
+ : [
397
+ [isReceipt ? 'Payment Date' : 'Invoice Date', formatDate(isReceipt ? receiptPaymentSummary.date : invoice.invoiceDate)],
398
+ [isReceipt ? 'Payment Method' : 'Due Date', isReceipt ? (receiptPaymentSummary.method ?? '(unknown)') : (formatDate(invoice.dueDate) ?? '(none on file)')],
399
+ ['Service Period', `${formatDate(invoice.servicePeriodStart) ?? '?'} - ${formatDate(invoice.servicePeriodEnd) ?? '?'}`],
400
+ ];
401
+ const dateValueX = datesX + 90;
402
+ const dateValueMaxWidth = rightEdge - dateValueX; // bounded so a long value (e.g. a wide service-period range) wraps instead of running past the right margin
403
+ let dateY = y + 10;
404
+ for (const [label, value] of dateRows) {
405
+ doc.font('Helvetica').fontSize(9).fillColor(COLOR.mutedText).text(label, datesX, dateY, { continued: false, width: dateValueX - datesX - 4 });
406
+ doc.font('Helvetica-Bold').fontSize(10).fillColor(COLOR.darkText).text(value ?? '(none on file)', dateValueX, dateY, { width: dateValueMaxWidth });
407
+ dateY += 15;
408
+ }
409
+
410
+ y += bandHeight + 20;
411
+
412
+ // ── Line items table ──
413
+ const lineItems = invoice.lineItems && invoice.lineItems.length > 0
414
+ ? invoice.lineItems
415
+ : [{ description: 'Services rendered', amountCents: invoice.totalCents }];
416
+
417
+ const descColWidth = printableWidth * 0.72;
418
+ const amtColWidth = printableWidth - descColWidth;
419
+
420
+ ensureSpace(24);
421
+ doc.rect(leftEdge, y, printableWidth, 24).fill(COLOR.darkNavy);
422
+ doc.font('Helvetica-Bold').fontSize(10).fillColor(COLOR.white)
423
+ .text('Description', leftEdge + 10, y + 7, { width: descColWidth - 10 });
424
+ doc.text('Amount', leftEdge + descColWidth, y + 7, { width: amtColWidth - 10, align: 'right' });
425
+ y += 24;
426
+
427
+ let alt = false;
428
+ for (const item of lineItems) {
429
+ const description = item.description ?? '';
430
+ doc.font('Helvetica').fontSize(10);
431
+ // Measure the actual wrapped height for THIS description at the column width it will be
432
+ // drawn in, rather than assuming a fixed single-line row -- a long description must not
433
+ // silently overlap the next row's background/text.
434
+ const textHeight = doc.heightOfString(description, { width: descColWidth - 20 });
435
+ const rowHeight = Math.max(24, textHeight + 14);
436
+
437
+ // A row that would run into the footer reserve flows onto a fresh page instead of being cut
438
+ // off or overlapping the totals/status/footer drawn below it -- see the many-line-item test.
439
+ ensureSpace(rowHeight);
440
+
441
+ if (alt) doc.rect(leftEdge, y, printableWidth, rowHeight).fill(COLOR.rowAlt);
442
+ alt = !alt;
443
+
444
+ doc.font('Helvetica').fontSize(10).fillColor(COLOR.darkText)
445
+ .text(description, leftEdge + 10, y + 7, { width: descColWidth - 20 });
446
+ doc.text(money(item.amountCents), leftEdge + descColWidth, y + 7, { width: amtColWidth - 10, align: 'right' });
447
+ y += rowHeight;
448
+ }
449
+
450
+ doc.moveTo(leftEdge, y).lineTo(rightEdge, y).lineWidth(1).strokeColor(COLOR.rule).stroke();
451
+ y += 20;
452
+
453
+ // ── Totals block (the historically buggy part -- see module doc comment) ──
454
+ const amountPaidCents = balance?.appliedCents ?? (isReceipt ? invoice.totalCents : 0);
455
+ const balanceDueCents = Math.max(0, invoice.totalCents - amountPaidCents);
456
+
457
+ const totalsRows = [];
458
+ if (isQuote) {
459
+ totalsRows.push({ label: 'Quoted Amount', value: money(invoice.totalCents), rule: true });
460
+ } else if (isReceipt) {
461
+ totalsRows.push({ label: 'Invoice Amount', value: money(invoice.totalCents) });
462
+ totalsRows.push({ label: 'Amount Paid', value: money(amountPaidCents), valueColor: COLOR.successGreen });
463
+ totalsRows.push({ label: 'Balance Due', value: money(0), rule: true, valueColor: COLOR.successGreen });
464
+ } else {
465
+ totalsRows.push({ label: 'Invoice Amount', value: money(invoice.totalCents) });
466
+ if (amountPaidCents > 0) {
467
+ totalsRows.push({ label: 'Amount Paid', value: money(amountPaidCents), valueColor: COLOR.successGreen });
468
+ }
469
+ totalsRows.push({
470
+ label: 'Balance Due',
471
+ value: money(balanceDueCents),
472
+ rule: true,
473
+ valueColor: balanceDueCents > 0 ? COLOR.amberDue : COLOR.successGreen,
474
+ });
475
+ }
476
+
477
+ // Generous overestimate of the totals block's own height (rowGap + max font size per row) --
478
+ // drawTotalsBlock computes the precise layout itself, this just keeps the block off a page edge.
479
+ ensureSpace(totalsRows.length * 30 + 10);
480
+ y = drawTotalsBlock(doc, totalsRows, { leftEdge, rightEdge, y });
481
+
482
+ // ── Status band (fills what would otherwise be dead space, per no-blank-filler rule) ──
483
+ y += 14;
484
+ ensureSpace(26 + 14);
485
+ // Only append "(Net N)" when the invoice's actual invoiceDate->dueDate gap really is N days --
486
+ // subscription.netTermsDays is the subscription's STANDARD terms, which don't necessarily match
487
+ // this specific invoice's due date (e.g. a shortened or negotiated deadline). Showing "(Net 30)"
488
+ // next to a due date that's actually 9 days out is a real, visible inconsistency, not cosmetic --
489
+ // exactly the kind of thing a disputing customer's counsel would flag.
490
+ const actualNetDays = invoice.dueDate && invoice.invoiceDate
491
+ ? Math.round((new Date(invoice.dueDate) - new Date(invoice.invoiceDate)) / 86400000)
492
+ : null;
493
+ const netTermsMatch = subscription?.netTermsDays && actualNetDays === subscription.netTermsDays;
494
+ const bandLabel = isQuote
495
+ ? 'This is a quote, not a bill -- no payment is due.'
496
+ : isReceipt
497
+ ? 'PAID IN FULL - thank you for your business.'
498
+ : balanceDueCents > 0
499
+ ? `Payment due ${formatDate(invoice.dueDate) ?? 'on receipt'}${netTermsMatch ? ` (Net ${subscription.netTermsDays})` : ''}.`
500
+ : 'PAID IN FULL.';
501
+ const statusColor = isQuote ? COLOR.primaryBlue : (isReceipt || balanceDueCents === 0 ? COLOR.successGreen : COLOR.primaryBlue);
502
+ doc.rect(leftEdge, y, printableWidth, 28).fill(statusColor);
503
+ doc.font('Helvetica-Bold').fontSize(10).fillColor(COLOR.white)
504
+ .text(bandLabel, leftEdge + 12, y + 9, { width: printableWidth - 24 });
505
+ y += 28;
506
+
507
+ // ── ACH / wire remittance info (unpaid invoices only, sourced from Key Vault) ──
508
+ if (bankInfo) {
509
+ y += 12;
510
+ doc.font('Helvetica').fontSize(8).fillColor(COLOR.mutedText)
511
+ .text(
512
+ `Account Number: ${bankInfo.accountNumber} | ACH Routing: ${bankInfo.achRouting} | Wire Routing: ${bankInfo.wireRouting} | SWIFT: ${bankInfo.swift}`,
513
+ leftEdge, y, { width: printableWidth }
514
+ );
515
+ }
516
+
517
+ // ── Footer ──
518
+ const footerY = pageHeight - PAGE_MARGIN - 18;
519
+ doc.moveTo(leftEdge, footerY).lineTo(rightEdge, footerY).lineWidth(1).strokeColor(COLOR.rule).stroke();
520
+ const copyrightYear = new Date().getFullYear();
521
+ doc.font('Helvetica').fontSize(8).fillColor(COLOR.mutedText)
522
+ .text(`© ${copyrightYear} Velaro Incorporated - billing@velaro.com`, leftEdge, footerY + 6, { width: printableWidth });
523
+
524
+ doc.end();
525
+ await done;
526
+ const buffer = Buffer.concat(chunks);
527
+
528
+ if (outPath) {
529
+ fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
530
+ fs.writeFileSync(outPath, buffer);
531
+ }
532
+
533
+ return buffer;
534
+ }