@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.
- package/README.md +9 -0
- package/assets/billing-doc-patterns/concentric-arcs.png +0 -0
- package/assets/billing-doc-patterns/diagonal-hatch.png +0 -0
- package/assets/billing-doc-patterns/dot-grid.png +0 -0
- package/assets/billing-doc-patterns/hex-grid.png +0 -0
- package/assets/billing-doc-patterns/wave-lines.png +0 -0
- package/assets/velaro-logo-lockup-full-color.png +0 -0
- package/bin/velaro.js +4 -0
- package/lib/commands/article.js +7 -3
- package/lib/commands/billing-subscription.js +610 -3
- package/lib/commands/contact-list.js +88 -1
- package/lib/commands/db.js +3 -0
- package/lib/commands/entitlement.js +24 -1
- package/lib/commands/login.js +7 -6
- package/lib/commands/logs.js +129 -5
- package/lib/commands/mcp-key.js +24 -14
- package/lib/commands/order-form.js +107 -0
- package/lib/commands/outbound-activity.js +124 -0
- package/lib/commands/subscription.js +61 -2
- package/lib/commands/support.js +19 -1
- package/lib/commands/teams.js +49 -1
- package/lib/config.js +24 -2
- package/lib/config.test.js +94 -0
- package/lib/kb-screenshot.js +120 -43
- package/lib/mcp-key.test.js +17 -0
- package/lib/pdf/billing-document.js +534 -0
- package/lib/pdf/billing-document.test.js +349 -0
- package/lib/site-flags.js +71 -0
- package/lib/site-flags.test.js +57 -0
- package/package.json +6 -3
|
@@ -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
|
+
}
|