@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,349 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import PDFDocument from 'pdfkit';
|
|
7
|
+
import { computeTotalsLayout, generateBillingDocument, money, PAGE_MARGIN, pickPatternIndex, pickPatternFile, PATTERN_FILES, PATTERN_DIR } from './billing-document.js';
|
|
8
|
+
|
|
9
|
+
const PRINTABLE_WIDTH = 612 - PAGE_MARGIN * 2; // US Letter minus both margins, same as the real generator
|
|
10
|
+
|
|
11
|
+
function makeDoc() {
|
|
12
|
+
// No destination needed -- widthOfString() works on an in-memory doc with the standard fonts.
|
|
13
|
+
return new PDFDocument({ size: 'LETTER', margin: PAGE_MARGIN });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── computeTotalsLayout: the printable-width assertion ──────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
test('computeTotalsLayout fits a normal invoice totals block within the real printable width', () => {
|
|
19
|
+
const doc = makeDoc();
|
|
20
|
+
const rows = [
|
|
21
|
+
{ label: 'Invoice Amount', value: money(735739700) },
|
|
22
|
+
{ label: 'Amount Paid', value: money(0) },
|
|
23
|
+
{ label: 'Balance Due', value: money(735739700) },
|
|
24
|
+
];
|
|
25
|
+
const layout = computeTotalsLayout(doc, rows, PRINTABLE_WIDTH);
|
|
26
|
+
assert.ok(layout.totalWidth <= PRINTABLE_WIDTH, `layout width ${layout.totalWidth} must not exceed ${PRINTABLE_WIDTH}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('computeTotalsLayout shrinks the value font to fit a huge amount in a constrained width before giving up', () => {
|
|
30
|
+
const doc = makeDoc();
|
|
31
|
+
// A synthetic, deliberately huge amount ($999,999,999,999.99) -- larger than any real invoice
|
|
32
|
+
// this system would generate, exactly the kind of case that overflowed the hand-built fpdf2
|
|
33
|
+
// receipt this feature replaces. Paired with a narrower-than-usual printable width (e.g. a
|
|
34
|
+
// tighter layout variant) so the default 13pt value font genuinely does not fit and the
|
|
35
|
+
// fallback ladder has to do real work, rather than trivially fitting on a full 512pt page.
|
|
36
|
+
const hugeValue = money(99999999999999);
|
|
37
|
+
const rows = [
|
|
38
|
+
{ label: 'Invoice Amount', value: hugeValue },
|
|
39
|
+
{ label: 'Amount Paid', value: money(0) },
|
|
40
|
+
{ label: 'Balance Due', value: hugeValue },
|
|
41
|
+
];
|
|
42
|
+
const constrainedWidth = 190;
|
|
43
|
+
const layout = computeTotalsLayout(doc, rows, constrainedWidth);
|
|
44
|
+
assert.ok(layout.totalWidth <= constrainedWidth, `layout width ${layout.totalWidth} must not exceed ${constrainedWidth}`);
|
|
45
|
+
// It should have had to shrink below the default largest size to make this fit.
|
|
46
|
+
assert.ok(layout.valueFontSize < 13, `expected the value font to shrink below 13pt, got ${layout.valueFontSize}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('computeTotalsLayout: a huge amount at the real page width still fits without needing to shrink (sanity check)', () => {
|
|
50
|
+
const doc = makeDoc();
|
|
51
|
+
const hugeValue = money(99999999999999);
|
|
52
|
+
const rows = [
|
|
53
|
+
{ label: 'Invoice Amount', value: hugeValue },
|
|
54
|
+
{ label: 'Balance Due', value: hugeValue },
|
|
55
|
+
];
|
|
56
|
+
const layout = computeTotalsLayout(doc, rows, PRINTABLE_WIDTH);
|
|
57
|
+
assert.ok(layout.totalWidth <= PRINTABLE_WIDTH);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('computeTotalsLayout throws a clear error instead of silently overflowing when nothing fits', () => {
|
|
61
|
+
const doc = makeDoc();
|
|
62
|
+
const hugeValue = money(99999999999999);
|
|
63
|
+
const rows = [
|
|
64
|
+
{ label: 'A Label So Absurdly Long It Cannot Possibly Share A Line With Anything Else', value: hugeValue },
|
|
65
|
+
];
|
|
66
|
+
// A printable width far narrower than any real page could produce -- guarantees every font
|
|
67
|
+
// size in the fallback ladder still overflows, so the assertion MUST throw rather than return
|
|
68
|
+
// a layout wider than the page.
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => computeTotalsLayout(doc, rows, 40),
|
|
71
|
+
/would overflow the printable width/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('computeTotalsLayout never returns a layout wider than printableWidth, across a range of widths', () => {
|
|
76
|
+
const doc = makeDoc();
|
|
77
|
+
const rows = [
|
|
78
|
+
{ label: 'Invoice Amount', value: money(123456789) },
|
|
79
|
+
{ label: 'Balance Due', value: money(123456789) },
|
|
80
|
+
];
|
|
81
|
+
for (const printableWidth of [512, 300, 200, 150]) {
|
|
82
|
+
try {
|
|
83
|
+
const layout = computeTotalsLayout(doc, rows, printableWidth);
|
|
84
|
+
assert.ok(layout.totalWidth <= printableWidth);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Throwing is an acceptable outcome (never silently overflowing is the actual contract) --
|
|
87
|
+
// but if it doesn't throw, the returned layout must genuinely fit.
|
|
88
|
+
assert.match(err.message, /would overflow the printable width/);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Background pattern selection: deterministic, full-set rotation ──────────────────────────
|
|
94
|
+
|
|
95
|
+
test('pickPatternIndex/pickPatternFile is deterministic — the same invoice number always picks the same pattern', () => {
|
|
96
|
+
const seeds = ['INV-50042', 'INV-50999', 4242, 'INV-00001'];
|
|
97
|
+
for (const seed of seeds) {
|
|
98
|
+
const first = pickPatternIndex(seed);
|
|
99
|
+
for (let i = 0; i < 10; i++) {
|
|
100
|
+
assert.equal(pickPatternIndex(seed), first, `pickPatternIndex(${seed}) must be stable across repeated calls`);
|
|
101
|
+
}
|
|
102
|
+
assert.equal(pickPatternFile(seed), PATTERN_FILES[first]);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('pickPatternIndex covers the full pattern set (not always index 0) across a range of invoice numbers', () => {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (let i = 1; i <= 500; i++) {
|
|
109
|
+
seen.add(pickPatternIndex(`INV-${50000 + i}`));
|
|
110
|
+
}
|
|
111
|
+
assert.equal(seen.size, PATTERN_FILES.length, `expected all ${PATTERN_FILES.length} patterns to be hit across 500 invoice numbers, saw indices: ${[...seen].sort().join(',')}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('pickPatternIndex always returns a valid index into PATTERN_FILES', () => {
|
|
115
|
+
const seeds = ['INV-50042', 0, 1, -5, '', null, undefined, 99999999999999, 'a very long invoice number formatted string INV-000123456'];
|
|
116
|
+
for (const seed of seeds) {
|
|
117
|
+
const idx = pickPatternIndex(seed);
|
|
118
|
+
assert.ok(Number.isInteger(idx) && idx >= 0 && idx < PATTERN_FILES.length, `pickPatternIndex(${seed}) returned out-of-range index ${idx}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('every file in PATTERN_FILES exists on disk and is a valid, non-trivial PNG', () => {
|
|
123
|
+
for (const filename of PATTERN_FILES) {
|
|
124
|
+
const filePath = path.join(PATTERN_DIR, filename);
|
|
125
|
+
assert.ok(fs.existsSync(filePath), `missing pattern asset: ${filePath}`);
|
|
126
|
+
const buf = fs.readFileSync(filePath);
|
|
127
|
+
assert.ok(buf.length > 1000, `${filename} is suspiciously small (${buf.length} bytes) to be a real pattern image`);
|
|
128
|
+
assert.equal(buf.toString('ascii', 1, 4), 'PNG', `${filename} does not look like a valid PNG`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('two invoices that resolve to different pattern indices produce PDFs (sanity: generation succeeds regardless of which pattern is picked)', async () => {
|
|
133
|
+
// Pick two invoice numbers known to land on different pattern indices so this test actually
|
|
134
|
+
// exercises pattern variety, not just re-running the same one twice.
|
|
135
|
+
let seedA = null, seedB = null;
|
|
136
|
+
for (let i = 1; i <= 1000 && (seedA === null || seedB === null); i++) {
|
|
137
|
+
const seed = `INV-${60000 + i}`;
|
|
138
|
+
if (seedA === null) { seedA = seed; continue; }
|
|
139
|
+
if (pickPatternIndex(seed) !== pickPatternIndex(seedA)) seedB = seed;
|
|
140
|
+
}
|
|
141
|
+
assert.ok(seedA && seedB, 'test setup: could not find two invoice numbers with different pattern indices');
|
|
142
|
+
assert.notEqual(pickPatternIndex(seedA), pickPatternIndex(seedB));
|
|
143
|
+
|
|
144
|
+
for (const invoiceNumberFormatted of [seedA, seedB]) {
|
|
145
|
+
const invoice = baseInvoice({ invoiceNumberFormatted });
|
|
146
|
+
const balance = { appliedCents: 0, totalCents: invoice.totalCents, remainingCents: invoice.totalCents, paidInFull: false };
|
|
147
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-pattern-${invoiceNumberFormatted}-${Date.now()}.pdf`);
|
|
148
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance, type: 'invoice', outPath: tmpFile });
|
|
149
|
+
assert.ok(buffer.length > 0);
|
|
150
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
151
|
+
fs.unlinkSync(tmpFile);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── generateBillingDocument: receipt guard + real PDF output ────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function baseInvoice(overrides = {}) {
|
|
158
|
+
return {
|
|
159
|
+
id: 4242,
|
|
160
|
+
subscriptionId: 7,
|
|
161
|
+
invoiceNumber: 50042,
|
|
162
|
+
invoiceNumberFormatted: 'INV-50042',
|
|
163
|
+
poNumber: 'PO-TEST-001',
|
|
164
|
+
customerName: 'Test Customer LLC',
|
|
165
|
+
invoiceDate: '2026-09-01T00:00:00Z',
|
|
166
|
+
dueDate: '2026-10-01T00:00:00Z',
|
|
167
|
+
servicePeriodStart: '2026-09-01T00:00:00Z',
|
|
168
|
+
servicePeriodEnd: '2027-08-31T00:00:00Z',
|
|
169
|
+
totalCents: 735739700,
|
|
170
|
+
status: 'sent',
|
|
171
|
+
lineItems: [
|
|
172
|
+
{ description: 'Annual subscription — Professional tier', amountCents: 735739700, sortOrder: 0 },
|
|
173
|
+
],
|
|
174
|
+
...overrides,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const baseSubscription = { id: 7, customerName: 'Test Customer LLC', netTermsDays: 30, billingCadence: 'annual', paymentMethod: 'ach' };
|
|
179
|
+
|
|
180
|
+
test('generateBillingDocument refuses --type receipt for an invoice that is not paid in full', async () => {
|
|
181
|
+
const invoice = baseInvoice();
|
|
182
|
+
const unpaidBalance = { appliedCents: 0, totalCents: invoice.totalCents, remainingCents: invoice.totalCents, paidInFull: false };
|
|
183
|
+
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
() => generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance: unpaidBalance, type: 'receipt' }),
|
|
186
|
+
/not recorded as paid in full/,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('generateBillingDocument refuses --type receipt when balance data is missing entirely', async () => {
|
|
191
|
+
const invoice = baseInvoice();
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
() => generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance: undefined, type: 'receipt' }),
|
|
194
|
+
/not recorded as paid in full/,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('generateBillingDocument refuses --type receipt for a PARTIALLY paid invoice (paidInFull=false even with some payment)', async () => {
|
|
199
|
+
const invoice = baseInvoice();
|
|
200
|
+
const partialBalance = { appliedCents: 100000, totalCents: invoice.totalCents, remainingCents: invoice.totalCents - 100000, paidInFull: false };
|
|
201
|
+
const payments = [{ id: 1, amountCents: 100000, method: 'ach', paymentDate: '2026-09-05T00:00:00Z', status: 'applied' }];
|
|
202
|
+
|
|
203
|
+
await assert.rejects(
|
|
204
|
+
() => generateBillingDocument({ invoice, subscription: baseSubscription, payments, balance: partialBalance, type: 'receipt' }),
|
|
205
|
+
/not recorded as paid in full/,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('generateBillingDocument produces a real, non-empty PDF for --type invoice', async () => {
|
|
210
|
+
const invoice = baseInvoice();
|
|
211
|
+
const balance = { appliedCents: 0, totalCents: invoice.totalCents, remainingCents: invoice.totalCents, paidInFull: false };
|
|
212
|
+
|
|
213
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-invoice-${Date.now()}.pdf`);
|
|
214
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance, type: 'invoice', outPath: tmpFile });
|
|
215
|
+
|
|
216
|
+
assert.ok(buffer.length > 0, 'PDF buffer must not be empty');
|
|
217
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-', 'buffer must start with a valid PDF header');
|
|
218
|
+
|
|
219
|
+
const fileBytes = fs.readFileSync(tmpFile);
|
|
220
|
+
assert.ok(fileBytes.length > 0);
|
|
221
|
+
assert.equal(fileBytes.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
222
|
+
fs.unlinkSync(tmpFile);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('generateBillingDocument produces a real, non-empty PDF for --type receipt on a paid-in-full invoice', async () => {
|
|
226
|
+
const invoice = baseInvoice();
|
|
227
|
+
const balance = { appliedCents: invoice.totalCents, totalCents: invoice.totalCents, remainingCents: 0, paidInFull: true };
|
|
228
|
+
const payments = [
|
|
229
|
+
{ id: 1, amountCents: invoice.totalCents, method: 'ach', paymentDate: '2026-09-10T00:00:00Z', status: 'applied' },
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-receipt-${Date.now()}.pdf`);
|
|
233
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments, balance, type: 'receipt', outPath: tmpFile });
|
|
234
|
+
|
|
235
|
+
assert.ok(buffer.length > 0, 'PDF buffer must not be empty');
|
|
236
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-', 'buffer must start with a valid PDF header');
|
|
237
|
+
|
|
238
|
+
const fileBytes = fs.readFileSync(tmpFile);
|
|
239
|
+
assert.ok(fileBytes.length > 0);
|
|
240
|
+
assert.equal(fileBytes.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
241
|
+
fs.unlinkSync(tmpFile);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('generateBillingDocument handles a synthetic huge invoice amount without overflowing (invoice type)', async () => {
|
|
245
|
+
const invoice = baseInvoice({ totalCents: 99999999999999, lineItems: [{ description: 'Synthetic overflow test line item', amountCents: 99999999999999, sortOrder: 0 }] });
|
|
246
|
+
const balance = { appliedCents: 0, totalCents: invoice.totalCents, remainingCents: invoice.totalCents, paidInFull: false };
|
|
247
|
+
|
|
248
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-overflow-${Date.now()}.pdf`);
|
|
249
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance, type: 'invoice', outPath: tmpFile });
|
|
250
|
+
|
|
251
|
+
assert.ok(buffer.length > 0);
|
|
252
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
253
|
+
fs.unlinkSync(tmpFile);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('generateBillingDocument rejects an unknown --type value', async () => {
|
|
257
|
+
const invoice = baseInvoice();
|
|
258
|
+
await assert.rejects(
|
|
259
|
+
() => generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance: null, type: 'statement' }),
|
|
260
|
+
/type must be "invoice", "receipt", or "quote"/,
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// NOTE: pdfkit's content streams are FlateDecode-compressed with indirect (object-referenced)
|
|
265
|
+
// /Length values, so a raw regex/inflate-based text search over the buffer is not reliable here
|
|
266
|
+
// (tried and confirmed brittle -- it fails to find even a real invoice's own INV- number, a false
|
|
267
|
+
// negative). The identifier-leak behavior below (title/ref text, no due-date framing) was verified
|
|
268
|
+
// by generating a real quote PDF from a live invoice record and visually inspecting the rendered
|
|
269
|
+
// page -- see the PR description. These tests assert only what's reliably checkable from outside
|
|
270
|
+
// the PDF's compressed byte stream: that quote generation succeeds and produces a structurally
|
|
271
|
+
// valid, non-empty PDF, same bar as the existing invoice/receipt tests above.
|
|
272
|
+
test('generateBillingDocument accepts --type quote and produces a valid, non-empty PDF', async () => {
|
|
273
|
+
const invoice = baseInvoice();
|
|
274
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-quote-${Date.now()}.pdf`);
|
|
275
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance: null, type: 'quote', outPath: tmpFile });
|
|
276
|
+
|
|
277
|
+
assert.ok(buffer.length > 0);
|
|
278
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
279
|
+
const fileBytes = fs.readFileSync(tmpFile);
|
|
280
|
+
assert.equal(fileBytes.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
281
|
+
fs.unlinkSync(tmpFile);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── Regressions found in review: long customer name / many line items ───────────────────────
|
|
285
|
+
|
|
286
|
+
test('a long customer name is bounded to wrap before the dates column instead of overflowing into it', () => {
|
|
287
|
+
// Regression check for the fix in generateBillingDocument: the customer-name text call must
|
|
288
|
+
// pass an explicit `width` bounded by where the dates column starts, not the full page width.
|
|
289
|
+
// This asserts the actual math used for that width leaves real room short of the printable
|
|
290
|
+
// right edge (i.e. it is bounded to roughly half the page, not left unbounded).
|
|
291
|
+
const doc = makeDoc();
|
|
292
|
+
const leftEdge = PAGE_MARGIN;
|
|
293
|
+
const rightEdge = 612 - PAGE_MARGIN;
|
|
294
|
+
const printableWidth = rightEdge - leftEdge;
|
|
295
|
+
const datesX = leftEdge + printableWidth / 2;
|
|
296
|
+
const nameColWidth = datesX - (leftEdge + 12) - 10;
|
|
297
|
+
|
|
298
|
+
assert.ok(nameColWidth < printableWidth, 'the bounded name column must be narrower than the full printable width');
|
|
299
|
+
assert.ok(nameColWidth > 0);
|
|
300
|
+
|
|
301
|
+
// A realistic long legal entity name should wrap (need more than one line) at this width and
|
|
302
|
+
// font, rather than being drawn on a single line wide enough to run past datesX.
|
|
303
|
+
doc.font('Helvetica-Bold').fontSize(11);
|
|
304
|
+
const longName = 'Consolidated International Holdings and Logistics Partners, LLC';
|
|
305
|
+
const singleLineWidth = doc.widthOfString(longName);
|
|
306
|
+
assert.ok(singleLineWidth > nameColWidth, 'test name must actually be wide enough to require wrapping at this column width for this assertion to be meaningful');
|
|
307
|
+
const wrappedHeight = doc.heightOfString(longName, { width: nameColWidth });
|
|
308
|
+
const singleLineHeight = doc.heightOfString('X', { width: nameColWidth });
|
|
309
|
+
assert.ok(wrappedHeight > singleLineHeight, 'a long name must wrap to multiple lines within the bounded width instead of overflowing on one line');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('generateBillingDocument does not throw for a very long customer name and produces a valid PDF', async () => {
|
|
313
|
+
const invoice = baseInvoice({ customerName: 'Consolidated International Holdings and Logistics Partners, LLC (Formerly Trading As Northbridge Freight Solutions)' });
|
|
314
|
+
const balance = { appliedCents: 0, totalCents: invoice.totalCents, remainingCents: invoice.totalCents, paidInFull: false };
|
|
315
|
+
|
|
316
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-longname-${Date.now()}.pdf`);
|
|
317
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance, type: 'invoice', outPath: tmpFile });
|
|
318
|
+
|
|
319
|
+
assert.ok(buffer.length > 0);
|
|
320
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
321
|
+
fs.unlinkSync(tmpFile);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('generateBillingDocument paginates instead of overflowing when an invoice has many line items', async () => {
|
|
325
|
+
// Regression check for the pagination bug found in review: a heavily itemized invoice used to
|
|
326
|
+
// push the totals/status/footer past the bottom of a single page with nothing to catch it.
|
|
327
|
+
const manyLineItems = Array.from({ length: 60 }, (_, i) => ({
|
|
328
|
+
description: `Adjustment line item number ${i + 1} — service usage overage for period`,
|
|
329
|
+
amountCents: 12345 + i,
|
|
330
|
+
}));
|
|
331
|
+
const totalCents = manyLineItems.reduce((sum, li) => sum + li.amountCents, 0);
|
|
332
|
+
const invoice = baseInvoice({ lineItems: manyLineItems, totalCents });
|
|
333
|
+
const balance = { appliedCents: 0, totalCents, remainingCents: totalCents, paidInFull: false };
|
|
334
|
+
|
|
335
|
+
const tmpFile = path.join(os.tmpdir(), `billing-doc-test-manylines-${Date.now()}.pdf`);
|
|
336
|
+
const buffer = await generateBillingDocument({ invoice, subscription: baseSubscription, payments: [], balance, type: 'invoice', outPath: tmpFile });
|
|
337
|
+
|
|
338
|
+
assert.ok(buffer.length > 0);
|
|
339
|
+
assert.equal(buffer.subarray(0, 5).toString('latin1'), '%PDF-');
|
|
340
|
+
|
|
341
|
+
// Count real page objects (/Type /Page, not /Type /Pages) in the raw PDF body -- pdfkit does
|
|
342
|
+
// not compress these dictionary objects, so a plain substring count is a reliable enough proxy
|
|
343
|
+
// for "did this actually paginate" without pulling in a full PDF parser as a dependency.
|
|
344
|
+
const raw = buffer.toString('latin1');
|
|
345
|
+
const pageObjectCount = (raw.match(/\/Type\s*\/Page(?!s)/g) || []).length;
|
|
346
|
+
assert.ok(pageObjectCount > 1, `expected more than one page for a 60-line-item invoice, found ${pageObjectCount}`);
|
|
347
|
+
|
|
348
|
+
fs.unlinkSync(tmpFile);
|
|
349
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure logic for `velaro subscription set-flags` (no I/O), so it can be unit tested.
|
|
2
|
+
// Only Enable* boolean entitlement flags on ONE site's Subscriptions row may be flipped.
|
|
3
|
+
// Billing, package, price, limit (Max*/Retention) and identity fields are never allowed.
|
|
4
|
+
|
|
5
|
+
export const FLAG_PATTERN = /^enable[A-Za-z0-9]+$/i;
|
|
6
|
+
|
|
7
|
+
// Belt and braces: even if an Enable* column ever gets added that touches money/plan state,
|
|
8
|
+
// these substrings keep it off the allowlist until someone deliberately edits this file.
|
|
9
|
+
export const DENY_SUBSTRINGS = ['billing', 'stripe', 'package', 'price', 'payment', 'plan', 'trial', 'invoice', 'quickbooks', 'expir', 'autoupgrade'];
|
|
10
|
+
|
|
11
|
+
export function findKey(row, name) {
|
|
12
|
+
const want = String(name).toLowerCase();
|
|
13
|
+
return Object.keys(row || {}).find(k => k.toLowerCase() === want);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseFlagList(v) {
|
|
17
|
+
if (v === undefined || v === null || v === true || v === '') return [];
|
|
18
|
+
return String(v).split(',').map(s => s.trim()).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns { ok, errors, changes: [{flag, key, before, after}], body }
|
|
22
|
+
export function planChanges(row, enable, disable) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
const all = [...enable, ...disable];
|
|
25
|
+
if (all.length === 0) errors.push('Nothing to do: pass --enable and/or --disable.');
|
|
26
|
+
const lower = all.map(f => f.toLowerCase());
|
|
27
|
+
const dup = all.filter((f, i) => lower.indexOf(f.toLowerCase()) !== i);
|
|
28
|
+
if (dup.length) errors.push(`Flag(s) listed more than once (or in both --enable and --disable): ${[...new Set(dup)].join(', ')}`);
|
|
29
|
+
|
|
30
|
+
const changes = [];
|
|
31
|
+
const body = {};
|
|
32
|
+
for (const [flags, after] of [[enable, true], [disable, false]]) {
|
|
33
|
+
for (const flag of flags) {
|
|
34
|
+
if (!FLAG_PATTERN.test(flag)) { errors.push(`${flag}: not allowed. Only boolean Enable* flags may be set (no billing, package or limit fields).`); continue; }
|
|
35
|
+
const low = flag.toLowerCase();
|
|
36
|
+
const bad = DENY_SUBSTRINGS.find(s => low.includes(s));
|
|
37
|
+
if (bad) { errors.push(`${flag}: denied (matches billing/package guard "${bad}").`); continue; }
|
|
38
|
+
const key = findKey(row, flag);
|
|
39
|
+
if (!key) { errors.push(`${flag}: no such column on this site's subscription row.`); continue; }
|
|
40
|
+
if (typeof row[key] !== 'boolean') { errors.push(`${flag}: current value is not a boolean (${JSON.stringify(row[key])}); refusing.`); continue; }
|
|
41
|
+
changes.push({ flag, key, before: row[key], after });
|
|
42
|
+
body[flag] = after;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ok: errors.length === 0, errors, changes, body };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Every field whose value differs between two rows.
|
|
49
|
+
export function diffRows(before, after) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
|
|
52
|
+
for (const k of keys) {
|
|
53
|
+
const b = before?.[k], a = after?.[k];
|
|
54
|
+
if (JSON.stringify(b) !== JSON.stringify(a)) out.push({ key: k, before: b, after: a });
|
|
55
|
+
}
|
|
56
|
+
return out.sort((x, y) => x.key.localeCompare(y.key));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fields the server legitimately rewrites on any update.
|
|
60
|
+
const AUDIT_FIELDS = ['updatedat', 'modifiedat', 'lastmodified', 'updatedby', 'modifiedby', 'rowversion', 'timestamp'];
|
|
61
|
+
|
|
62
|
+
export function verifyOnlyIntendedChanged(before, after, changes) {
|
|
63
|
+
const intended = new Set(changes.map(c => c.key.toLowerCase()));
|
|
64
|
+
const diff = diffRows(before, after);
|
|
65
|
+
const unexpected = diff.filter(d => !intended.has(d.key.toLowerCase()) && !AUDIT_FIELDS.includes(d.key.toLowerCase()));
|
|
66
|
+
const missing = changes.filter(c => {
|
|
67
|
+
const k = findKey(after, c.key);
|
|
68
|
+
return !k || after[k] !== c.after;
|
|
69
|
+
});
|
|
70
|
+
return { ok: unexpected.length === 0 && missing.length === 0, unexpected, missing, diff };
|
|
71
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { planChanges, parseFlagList, diffRows, verifyOnlyIntendedChanged } from './site-flags.js';
|
|
4
|
+
|
|
5
|
+
const row = { siteId: 20365, enableAgentDashboard: false, enableCustomDashboards: false, enableAI: true, maxCustomDashboards: 3, billingEnabled: true, packageId: 4 };
|
|
6
|
+
|
|
7
|
+
test('parseFlagList splits, trims, drops empties', () => {
|
|
8
|
+
assert.deepEqual(parseFlagList(' a, b ,,c'), ['a', 'b', 'c']);
|
|
9
|
+
assert.deepEqual(parseFlagList(undefined), []);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('plans an enable/disable and builds the request body', () => {
|
|
13
|
+
const p = planChanges(row, ['EnableAgentDashboard', 'enableCustomDashboards'], ['EnableAI']);
|
|
14
|
+
assert.equal(p.ok, true);
|
|
15
|
+
assert.deepEqual(p.body, { EnableAgentDashboard: true, enableCustomDashboards: true, EnableAI: false });
|
|
16
|
+
assert.deepEqual(p.changes.map(c => [c.key, c.before, c.after]),
|
|
17
|
+
[['enableAgentDashboard', false, true], ['enableCustomDashboards', false, true], ['enableAI', true, false]]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('refuses limit, billing, package and identity fields', () => {
|
|
21
|
+
for (const f of ['MaxCustomDashboards', 'BillingEnabled', 'PackageId', 'SiteId']) {
|
|
22
|
+
assert.equal(planChanges(row, [f], []).ok, false, f);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('refuses Enable* flags matching the billing/package deny guard', () => {
|
|
27
|
+
const r = { ...row, enableStripePayments: false, enablePackageAutoUpgrade: false };
|
|
28
|
+
assert.equal(planChanges(r, ['EnableStripePayments'], []).ok, false);
|
|
29
|
+
assert.equal(planChanges(r, ['EnablePackageAutoUpgrade'], []).ok, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('refuses unknown column, non-boolean column, duplicates and empty request', () => {
|
|
33
|
+
assert.equal(planChanges(row, ['EnableNope'], []).ok, false);
|
|
34
|
+
assert.equal(planChanges({ ...row, enableWeird: 5 }, ['EnableWeird'], []).ok, false);
|
|
35
|
+
assert.equal(planChanges(row, ['EnableAI'], ['EnableAI']).ok, false);
|
|
36
|
+
assert.equal(planChanges(row, [], []).ok, false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('diffRows lists only changed keys', () => {
|
|
40
|
+
assert.deepEqual(diffRows({ a: 1, b: 2 }, { a: 1, b: 3 }), [{ key: 'b', before: 2, after: 3 }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('verify passes when only intended flags (plus audit fields) changed', () => {
|
|
44
|
+
const p = planChanges(row, ['EnableAgentDashboard'], []);
|
|
45
|
+
const after = { ...row, enableAgentDashboard: true, updatedAt: 'x' };
|
|
46
|
+
assert.equal(verifyOnlyIntendedChanged(row, after, p.changes).ok, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('verify fails on an unexpected changed field or an unapplied flag', () => {
|
|
50
|
+
const p = planChanges(row, ['EnableAgentDashboard'], []);
|
|
51
|
+
const v1 = verifyOnlyIntendedChanged(row, { ...row, enableAgentDashboard: true, maxCustomDashboards: 9 }, p.changes);
|
|
52
|
+
assert.equal(v1.ok, false);
|
|
53
|
+
assert.equal(v1.unexpected[0].key, 'maxCustomDashboards');
|
|
54
|
+
const v2 = verifyOnlyIntendedChanged(row, { ...row }, p.changes);
|
|
55
|
+
assert.equal(v2.ok, false);
|
|
56
|
+
assert.equal(v2.missing.length, 1);
|
|
57
|
+
});
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velaro/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.55",
|
|
4
4
|
"description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"velaro": "bin/velaro.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"postinstall": "node scripts/postinstall.js"
|
|
10
|
+
"postinstall": "node scripts/postinstall.js",
|
|
11
|
+
"test": "node --test lib/*.test.js"
|
|
11
12
|
},
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=18.0.0"
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"@azure/identity": "^4.4.1",
|
|
17
18
|
"@azure/keyvault-secrets": "^4.9.0",
|
|
18
19
|
"mssql": "^12.7.0",
|
|
20
|
+
"pdfkit": "^0.15.2",
|
|
19
21
|
"yargs": "^17.7.2"
|
|
20
22
|
},
|
|
21
23
|
"optionalDependencies": {
|
|
@@ -29,6 +31,7 @@
|
|
|
29
31
|
"files": [
|
|
30
32
|
"bin/",
|
|
31
33
|
"lib/",
|
|
32
|
-
"scripts/"
|
|
34
|
+
"scripts/",
|
|
35
|
+
"assets/"
|
|
33
36
|
]
|
|
34
37
|
}
|