@velaro/cli 1.4.54 → 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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// velaro billing-subscription — standalone, entitlement-decoupled recurring billing subscriptions
|
|
2
2
|
// (BillingSubscriptionController). See project_billing_subscription_target_decoupling.md — these
|
|
3
3
|
// records do not require a v20 SiteId and work for any product (v10/v20/none).
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import { request } from '../api.js';
|
|
6
|
+
import { generateBillingDocument } from '../pdf/billing-document.js';
|
|
5
7
|
|
|
6
8
|
function money(cents) {
|
|
7
9
|
return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
@@ -9,7 +11,7 @@ function money(cents) {
|
|
|
9
11
|
|
|
10
12
|
async function createSubscription(args) {
|
|
11
13
|
if (!args.customer || !args.start || !args.end) {
|
|
12
|
-
console.error('Usage: velaro billing-subscription create --customer "Liberty Mutual" --start 2026-11-17 --end 2027-11-16 [--po 4500175509] [--cadence annual] [--payment-method cash] [--total-cents 7357397] [--net-terms 30] [--lead-days 30] [--enable-billing] [--stripe-customer-id cus_...]');
|
|
14
|
+
console.error('Usage: velaro billing-subscription create --customer "Liberty Mutual" --start 2026-11-17 --end 2027-11-16 [--po 4500175509] [--cadence annual] [--payment-method cash] [--total-cents 7357397] [--net-terms 30] [--lead-days 30] [--enable-billing] [--stripe-customer-id cus_...] [--force]');
|
|
13
15
|
process.exit(1);
|
|
14
16
|
}
|
|
15
17
|
const body = {
|
|
@@ -28,13 +30,43 @@ async function createSubscription(args) {
|
|
|
28
30
|
netTermsDays: args.netTerms ?? 30,
|
|
29
31
|
cancellationNoticeDays: args.cancellationNoticeDays ?? 30,
|
|
30
32
|
totalContractValueCents: args.totalCents ?? 0,
|
|
33
|
+
recurringAmountCents: args.recurringAmountCents,
|
|
31
34
|
stripeCustomerId: args.stripeCustomerId,
|
|
32
35
|
notes: args.notes,
|
|
36
|
+
force: !!args.force,
|
|
33
37
|
};
|
|
34
38
|
const result = await request('POST', '/api/BillingSubscription', body);
|
|
35
39
|
console.log(`Subscription created: ID=${result.id}`);
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
async function deleteSubscription(id, args) {
|
|
43
|
+
const sub = await request('GET', `/api/BillingSubscription/${id}`);
|
|
44
|
+
const invoices = Array.isArray(sub.invoices) ? sub.invoices : [];
|
|
45
|
+
console.log(`\nAbout to permanently delete BillingSubscription ${id}\n${'-'.repeat(60)}`);
|
|
46
|
+
console.log(` Customer: ${sub.customerName}`);
|
|
47
|
+
console.log(` Period: ${sub.serviceStartDate?.slice(0, 10)} .. ${sub.serviceEndDate?.slice(0, 10)}`);
|
|
48
|
+
console.log(` Invoices to be cascade-deleted (${invoices.length}):`);
|
|
49
|
+
for (const inv of invoices) {
|
|
50
|
+
console.log(` - ${inv.invoiceNumberFormatted} ${money(inv.totalCents)} status=${inv.status}`);
|
|
51
|
+
}
|
|
52
|
+
if (!args.confirm) {
|
|
53
|
+
console.log('\nAdd --confirm to actually delete this subscription. No changes made.\n');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const result = await request('DELETE', `/api/BillingSubscription/${id}`);
|
|
57
|
+
console.log(`\n\x1b[32mDeleted subscription ${result.subscriptionId} (${result.customerName}) and ${result.deletedInvoiceCount} invoice(s).\x1b[0m\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function deleteInvoice(invoiceId, args) {
|
|
61
|
+
if (!args.confirm) {
|
|
62
|
+
console.log(`\nAdd --confirm to permanently delete invoice ${invoiceId}. No changes made.`);
|
|
63
|
+
console.log('(Delete is refused server-side if the invoice has a recorded payment or has already been sent/paid/voided.)\n');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const result = await request('DELETE', `/api/BillingSubscription/invoice/${invoiceId}`);
|
|
67
|
+
console.log(`\x1b[32mDeleted invoice ${result.invoiceNumberFormatted} (id=${result.invoiceId}) from subscription ${result.subscriptionId}.\x1b[0m`);
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
async function attachStripeCustomer(id, args) {
|
|
39
71
|
if (!args.email && !args.stripeCustomerId) {
|
|
40
72
|
console.error('Usage: velaro billing-subscription attach-stripe-customer <id> --email billing@customer.com (looks up or creates a Stripe customer by email)');
|
|
@@ -103,7 +135,7 @@ async function getSubscription(id) {
|
|
|
103
135
|
|
|
104
136
|
async function generateInvoice(id, args) {
|
|
105
137
|
if (!args.lineDescription || args.amountCents == null) {
|
|
106
|
-
console.error('Usage: velaro billing-subscription invoice <id> --line-description "Annual" --amount-cents 7357397 [--pre-generate] [--po ...] [--invoice-date YYYY-MM-DD] [--due-date YYYY-MM-DD] [--service-start YYYY-MM-DD] [--service-end YYYY-MM-DD] [--notes ...]');
|
|
138
|
+
console.error('Usage: velaro billing-subscription invoice <id> --line-description "Annual" --amount-cents 7357397 [--pre-generate] [--po ...] [--invoice-date YYYY-MM-DD] [--due-date YYYY-MM-DD] [--service-start YYYY-MM-DD] [--service-end YYYY-MM-DD] [--notes ...] [--number-mode sequential|random-jitter] [--number-max-jitter 10] [--invoice-number 50042]');
|
|
107
139
|
process.exit(1);
|
|
108
140
|
}
|
|
109
141
|
const body = {
|
|
@@ -115,6 +147,9 @@ async function generateInvoice(id, args) {
|
|
|
115
147
|
isPreGenerated: !!args.preGenerate,
|
|
116
148
|
internalNotes: args.notes,
|
|
117
149
|
lineItems: [{ description: args.lineDescription, amountCents: Number(args.amountCents) }],
|
|
150
|
+
numberMode: args.numberMode ?? 'sequential',
|
|
151
|
+
numberMaxJitter: args.numberMaxJitter ?? 10,
|
|
152
|
+
manualInvoiceNumber: args.invoiceNumber != null ? Number(args.invoiceNumber) : undefined,
|
|
118
153
|
};
|
|
119
154
|
const invoice = await request('POST', `/api/BillingSubscription/${id}/invoice`, body);
|
|
120
155
|
console.log(`Invoice ${invoice.invoiceNumberFormatted} (id=${invoice.id}) — ${money(invoice.totalCents)}, status=${invoice.status}`);
|
|
@@ -134,6 +169,76 @@ async function recordPayment(invoiceId, args) {
|
|
|
134
169
|
};
|
|
135
170
|
const payment = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/payment`, body);
|
|
136
171
|
console.log(`Payment recorded: id=${payment.id}, amount=${money(payment.amountCents)}, method=${payment.method}`);
|
|
172
|
+
// The server-side shared payment-applied handler has already run (RecordPaymentAsync); show the
|
|
173
|
+
// resulting billing clock so the operator sees what the payment did.
|
|
174
|
+
try {
|
|
175
|
+
printExpiry(await request('GET', `/api/BillingSubscription/invoice/${invoiceId}/expiry`));
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.log(`(paid-through lookup failed: ${e.message})`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function printExpiry(e) {
|
|
182
|
+
const d = (v) => (v ? String(v).slice(0, 10) : '(none)');
|
|
183
|
+
console.log(`Subscription ${e.subscriptionId} (${e.customerName}): paid-through=${d(e.paidThroughAt)}, policy=${e.expiryPolicy}, grace=${e.gracePeriodDays}d, ` +
|
|
184
|
+
`effective-expiry=${e.expiryPolicy === 'NeverExpire' ? 'never' : d(e.effectiveExpiresAt)}` +
|
|
185
|
+
(e.overrideActive ? `, OVERRIDE until ${d(e.expiryOverrideUntil)} (${e.expiryOverrideReason ?? 'no reason'})` : ''));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function showExpiry(id) {
|
|
189
|
+
printExpiry(await request('GET', `/api/BillingSubscription/${id}/expiry`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function setExpiryPolicy(id, args) {
|
|
193
|
+
if (!args.policy || !args.reason) {
|
|
194
|
+
console.error('Usage: velaro billing-subscription set-expiry-policy <id> --policy Default|CustomGrace|NeverExpire [--grace-days N] --reason "..."');
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
printExpiry(await request('POST', `/api/BillingSubscription/${id}/expiry-policy`, {
|
|
198
|
+
expiryPolicy: args.policy,
|
|
199
|
+
gracePeriodDays: args.graceDays != null ? Number(args.graceDays) : undefined,
|
|
200
|
+
reason: args.reason,
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function sendInvoice(invoiceId, args) {
|
|
205
|
+
const res = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/send${args.force ? '?force=true' : ''}`, {});
|
|
206
|
+
if (res.alreadySent) console.log(`Invoice ${invoiceId} was already sent ${res.sentAt}; nothing sent (use --force to resend).`);
|
|
207
|
+
else console.log(`Invoice ${invoiceId} sent to ${res.recipient} at ${res.sentAt}${res.payLinkIncluded ? ' (pay link included)' : ' (no pay link)'}.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function printReminders(v) {
|
|
211
|
+
console.log(`Subscription ${v.id} (${v.customerName}): reminders ${v.enabled ? 'ON' : 'OFF'}`);
|
|
212
|
+
console.log(` cadence (days from due): ${v.cadence}${v.cadenceIsDefault ? ' (default)' : ''}`);
|
|
213
|
+
console.log(` contact email: ${v.contactEmail ?? '(none)'}; resolved recipient: ${v.resolvedRecipient ?? '(none, sends will fail)'}`);
|
|
214
|
+
console.log(` expiry policy: ${v.expiryPolicy}; effective expiry: ${v.effectiveExpiry ?? 'never'}`);
|
|
215
|
+
for (const i of v.openInvoices || []) {
|
|
216
|
+
console.log(` invoice ${i.id} ${i.invoiceNumberFormatted} due ${i.dueDate} sent ${i.sentAt ?? 'NO'} reminders ${i.reminderCount} last ${i.lastReminderSentAt ?? '-'}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function reminders(id, args) {
|
|
221
|
+
const change = args.on || args.off || args.cadence != null || args.email != null;
|
|
222
|
+
if (args.on && args.off) { console.error('Use only one of --on / --off.'); process.exit(1); }
|
|
223
|
+
if (!change) return printReminders(await request('GET', `/api/BillingSubscription/${id}/reminders`));
|
|
224
|
+
const body = {};
|
|
225
|
+
if (args.on) body.enabled = true;
|
|
226
|
+
if (args.off) body.enabled = false;
|
|
227
|
+
if (args.cadence != null) body.cadence = String(args.cadence);
|
|
228
|
+
if (args.email != null) body.contactEmail = String(args.email);
|
|
229
|
+
printReminders(await request('POST', `/api/BillingSubscription/${id}/reminders`, body));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function pushExpiry(id, args) {
|
|
233
|
+
if (!args.reason || (!args.clear && !args.until)) {
|
|
234
|
+
console.error('Usage: velaro billing-subscription push-expiry <id> --until YYYY-MM-DD --reason "..." (or --clear --reason "...")');
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
printExpiry(await request('POST', `/api/BillingSubscription/${id}/expiry-override`, {
|
|
238
|
+
until: args.until ? new Date(args.until).toISOString() : undefined,
|
|
239
|
+
reason: args.reason,
|
|
240
|
+
clear: !!args.clear,
|
|
241
|
+
}));
|
|
137
242
|
}
|
|
138
243
|
|
|
139
244
|
async function listPayments(invoiceId) {
|
|
@@ -153,6 +258,21 @@ async function qboStatus(invoiceId) {
|
|
|
153
258
|
}
|
|
154
259
|
}
|
|
155
260
|
|
|
261
|
+
async function invoiceSendLog(invoiceId) {
|
|
262
|
+
const result = await request('GET', `/api/BillingSubscription/invoice/${invoiceId}/send-log`);
|
|
263
|
+
const inv = result.invoice;
|
|
264
|
+
console.log(`Invoice ${inv.invoiceNumberFormatted}: SentAt = ${inv.sentAt ?? '(never)'}, failures = ${inv.sendFailureCount}${inv.lastSendError ? `, lastError = ${inv.lastSendError}` : ''}`);
|
|
265
|
+
if (result.log.length === 0) {
|
|
266
|
+
console.log(' No send attempts recorded.');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
for (const l of result.log) {
|
|
270
|
+
const statusUpdate = l.statusUpdatedAtUtc ? ` (updated ${l.statusUpdatedAtUtc})` : '';
|
|
271
|
+
const detail = l.statusDetail ? ` -- ${l.statusDetail}` : '';
|
|
272
|
+
console.log(` [${l.sentAtUtc}] ${l.recipientRole} ${l.recipientEmail}: ${l.status}${statusUpdate}${detail} (acsMessageId=${l.acsMessageId ?? 'none'})`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
156
276
|
async function provisionPlaceholderSite(id, args) {
|
|
157
277
|
if (!args.tier) {
|
|
158
278
|
console.error('Usage: velaro billing-subscription provision-placeholder-site <id> --tier Professional [--admin-email placeholder+staxbill-123@velaro.com] [--preview]');
|
|
@@ -211,6 +331,40 @@ async function setAutoRenew(id, args) {
|
|
|
211
331
|
console.log(`Subscription ${result.id}: AutoRenew = ${result.autoRenew}`);
|
|
212
332
|
}
|
|
213
333
|
|
|
334
|
+
async function cancelSub(id, args) {
|
|
335
|
+
if (!args.mode) {
|
|
336
|
+
console.error('Usage: velaro billing-subscription cancel <id> --mode bill_more_periods|shutdown_accelerate|shutdown_keep_recognizing|shutdown_after_covered [--extra-periods N] [--effective-date YYYY-MM-DD] [--reason "..."]');
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
const r = await request('POST', `/api/BillingSubscription/${id}/cancel`, {
|
|
340
|
+
mode: args.mode, extraPeriods: args.extraPeriods, effectiveDate: args.effectiveDate, reason: args.reason,
|
|
341
|
+
});
|
|
342
|
+
console.log(`Subscription ${r.subscriptionId}: ${r.mode}, service ends ${r.serviceEndDate}, accelerated cents ${r.acceleratedCents}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function oneTimeInvoice(id, args) {
|
|
346
|
+
if (!args.description || args.amountCents == null) {
|
|
347
|
+
console.error('Usage: velaro billing-subscription one-time-invoice <id> --description "..." --amount-cents N [--date YYYY-MM-DD] [--po X]');
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
const r = await request('POST', `/api/BillingSubscription/${id}/one-time-invoice`, {
|
|
351
|
+
invoiceDate: args.date, poNumber: args.po,
|
|
352
|
+
lineItems: [{ description: args.description, amountCents: args.amountCents }],
|
|
353
|
+
});
|
|
354
|
+
console.log(`One-time invoice ${r.invoiceNumber} (id ${r.id}) total ${money(r.totalCents)} status ${r.status}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function midTermUpgrade(id, args) {
|
|
358
|
+
if (args.newAmountCents == null) {
|
|
359
|
+
console.error('Usage: velaro billing-subscription mid-term-upgrade <id> --new-amount-cents N [--effective-date YYYY-MM-DD] [--reason "..."]');
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
const r = await request('POST', `/api/BillingSubscription/${id}/mid-term-upgrade`, {
|
|
363
|
+
newRecurringAmountCents: args.newAmountCents, effectiveDate: args.effectiveDate, reason: args.reason,
|
|
364
|
+
});
|
|
365
|
+
console.log(`Subscription ${r.subscriptionId}: ${money(r.oldRecurringAmountCents)} -> ${money(r.newRecurringAmountCents)}, proration ${money(r.prorationCents)} co-terminous to ${r.coTermEnd}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
214
368
|
async function scheduleCadenceChange(id, args) {
|
|
215
369
|
if (args.cadence && !args.effectiveAt) {
|
|
216
370
|
console.error('Usage: velaro billing-subscription schedule-cadence-change <id> --cadence annual|monthly|quarterly --effective-at YYYY-MM-DD');
|
|
@@ -228,6 +382,334 @@ async function scheduleCadenceChange(id, args) {
|
|
|
228
382
|
}
|
|
229
383
|
}
|
|
230
384
|
|
|
385
|
+
async function correctPayment(paymentId, args) {
|
|
386
|
+
if (!args.field || args.reason == null) {
|
|
387
|
+
console.error('Usage: velaro billing-subscription payment-correct <paymentId> --field date|amount-cents|method|reference|note --value <v> --reason "..."');
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
const body = { field: args.field, value: args.value != null ? String(args.value) : undefined, reason: args.reason };
|
|
391
|
+
const result = await request('POST', `/api/BillingSubscription/payment/${paymentId}/correct`, body);
|
|
392
|
+
console.log(`Payment ${result.id} corrected: ${args.field} -> ${args.value}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function paymentHistory(paymentId) {
|
|
396
|
+
const result = await request('GET', `/api/BillingSubscription/payment/${paymentId}/history`);
|
|
397
|
+
console.log(`\nPayment ${paymentId} -- live value\n${'-'.repeat(60)}`);
|
|
398
|
+
console.log(` Amount: ${money(result.payment.amountCents)}`);
|
|
399
|
+
console.log(` Date: ${result.payment.paymentDate?.slice(0, 10)}`);
|
|
400
|
+
console.log(` Method: ${result.payment.method}`);
|
|
401
|
+
console.log(` Reference: ${result.payment.reference ?? '(none)'}`);
|
|
402
|
+
console.log(` Note: ${result.payment.note ?? '(none)'}`);
|
|
403
|
+
console.log(`\nCorrection history (${result.corrections.length})\n${'-'.repeat(60)}`);
|
|
404
|
+
for (const c of result.corrections) {
|
|
405
|
+
console.log(` [${c.correctedAt?.slice(0, 19)}] ${c.fieldName}: "${c.oldValue}" -> "${c.newValue}" (by ${c.correctedBy}: ${c.reason})`);
|
|
406
|
+
}
|
|
407
|
+
console.log('');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function correctInvoice(invoiceId, args) {
|
|
411
|
+
if (!args.field || args.reason == null) {
|
|
412
|
+
console.error('Usage: velaro billing-subscription invoice-correct <invoiceId> --field po-number|invoice-date|due-date|total-cents|internal-notes|service-period-start|service-period-end --value <v> --reason "..."');
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
const body = { field: args.field, value: args.value != null ? String(args.value) : undefined, reason: args.reason };
|
|
416
|
+
const result = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/correct`, body);
|
|
417
|
+
console.log(`Invoice ${result.invoiceNumberFormatted} (id=${result.id}) corrected: ${args.field} -> ${args.value}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function invoiceHistory(invoiceId) {
|
|
421
|
+
const result = await request('GET', `/api/BillingSubscription/invoice/${invoiceId}/history`);
|
|
422
|
+
console.log(`\nInvoice ${result.invoice.invoiceNumberFormatted} -- live value\n${'-'.repeat(60)}`);
|
|
423
|
+
console.log(` Total: ${money(result.invoice.totalCents)}`);
|
|
424
|
+
console.log(` PO: ${result.invoice.poNumber ?? '(none)'}`);
|
|
425
|
+
console.log(` Invoice date / due date: ${result.invoice.invoiceDate?.slice(0, 10)} / ${result.invoice.dueDate?.slice(0, 10)}`);
|
|
426
|
+
console.log(`\nCorrection history (${result.corrections.length})\n${'-'.repeat(60)}`);
|
|
427
|
+
for (const c of result.corrections) {
|
|
428
|
+
console.log(` [${c.correctedAt?.slice(0, 19)}] ${c.fieldName}: "${c.oldValue}" -> "${c.newValue}" (by ${c.correctedBy}: ${c.reason})`);
|
|
429
|
+
}
|
|
430
|
+
console.log('');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function correctSubscription(subscriptionId, args) {
|
|
434
|
+
if (!args.field || !args.reason) {
|
|
435
|
+
console.error('Usage: velaro billing-subscription subscription-correct <id> --field service-start-date|service-end-date|next-bill-date|po-number|notes|recurring-amount-cents|target-system|target-account-id|parallel-billing-mode|billing-cutover-date|external-billing-system|external-billing-account-id|auto-charge-enabled|invoice-delivery-action|invoice-recipient-emails|invoice-cc-emails|invoice-bcc-emails|contract-id|grace-period-days|auto-expire-after-grace-period|exclude-from-entitlement-sync|exclude-from-bulk-price-import --value <v> --reason "..." [--clear] [--force]');
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
const body = {
|
|
439
|
+
field: args.field,
|
|
440
|
+
value: args.value != null ? String(args.value) : undefined,
|
|
441
|
+
reason: args.reason,
|
|
442
|
+
clear: !!args.clear,
|
|
443
|
+
force: !!args.force,
|
|
444
|
+
};
|
|
445
|
+
const result = await request('POST', `/api/BillingSubscription/${subscriptionId}/correct`, body);
|
|
446
|
+
const sub = result.subscription;
|
|
447
|
+
const shownValue = args.clear ? '(cleared)' : (args.value ?? '(cleared/none)');
|
|
448
|
+
console.log(`Subscription ${sub.id} (${sub.customerName}) corrected: ${args.field} -> ${shownValue}`);
|
|
449
|
+
if (result.invoicesNeedingReconciliation && result.invoicesNeedingReconciliation.length > 0) {
|
|
450
|
+
console.log(`\n\x1b[33mWARNING\x1b[0m: ${result.invoicesNeedingReconciliation.length} existing invoice(s) on this subscription were NOT updated: ids ${result.invoicesNeedingReconciliation.join(', ')}.`);
|
|
451
|
+
console.log(` Reconcile each with: velaro billing-subscription invoice-correct <invoiceId> --field service-period-start|service-period-end --value <v> --reason "..."`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function subscriptionHistory(subscriptionId) {
|
|
456
|
+
const result = await request('GET', `/api/BillingSubscription/${subscriptionId}/history`);
|
|
457
|
+
const sub = result.subscription;
|
|
458
|
+
console.log(`\nSubscription ${sub.id} (${sub.customerName}) -- live value\n${'-'.repeat(60)}`);
|
|
459
|
+
console.log(` Service period: ${sub.serviceStartDate?.slice(0, 10)} -- ${sub.serviceEndDate?.slice(0, 10)}`);
|
|
460
|
+
console.log(` Next bill date: ${sub.nextBillDate?.slice(0, 10) ?? '(none)'}`);
|
|
461
|
+
console.log(` PO: ${sub.poNumber ?? '(none)'}`);
|
|
462
|
+
console.log(`\nCorrection history (${result.corrections.length})\n${'-'.repeat(60)}`);
|
|
463
|
+
for (const c of result.corrections) {
|
|
464
|
+
console.log(` [${c.correctedAt?.slice(0, 19)}] ${c.fieldName}: "${c.oldValue}" -> "${c.newValue}" (by ${c.correctedBy}: ${c.reason})`);
|
|
465
|
+
}
|
|
466
|
+
console.log('');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function reissueInvoice(invoiceId, args) {
|
|
470
|
+
if (!args.reason) {
|
|
471
|
+
console.error('Usage: velaro billing-subscription invoice-reissue <invoiceId> --reason "..." [--service-period-start <date>] [--service-period-end <date>] [--po-number <v>]');
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
const body = {
|
|
475
|
+
reason: args.reason,
|
|
476
|
+
servicePeriodStart: args.servicePeriodStart,
|
|
477
|
+
servicePeriodEnd: args.servicePeriodEnd,
|
|
478
|
+
poNumber: args.poNumber,
|
|
479
|
+
internalNotes: args.internalNotes,
|
|
480
|
+
};
|
|
481
|
+
const result = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/reissue`, body);
|
|
482
|
+
console.log(`Invoice ${invoiceId} superseded. New invoice: ${result.invoiceNumberFormatted} (id=${result.id}).`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function releaseInvoice(invoiceId) {
|
|
486
|
+
const r = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/release`, {});
|
|
487
|
+
console.log(`Invoice ${invoiceId} released: status ${r.status}, sends at ${r.readyToSendAt?.slice(0, 10)}.`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function testFireInvoice(invoiceId) {
|
|
491
|
+
const r = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/test-fire`, {});
|
|
492
|
+
console.log(`Test-fire sent to internal recipients only: ${(r.recipients || []).join(', ')}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function issueNow(id, args) {
|
|
496
|
+
const path = args.subscription
|
|
497
|
+
? `/api/BillingSubscription/${id}/issue-now`
|
|
498
|
+
: `/api/BillingSubscription/invoice/${id}/issue-now`;
|
|
499
|
+
const r = await request('POST', path, {});
|
|
500
|
+
console.log(`Issued now: ${r.invoiceNumberFormatted} (id=${r.id}), dated ${r.invoiceDate?.slice(0, 10)}, status ${r.status}.`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function regenerateInvoice(invoiceId) {
|
|
504
|
+
const r = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/regenerate`, {});
|
|
505
|
+
console.log(`Regenerated: ${r.invoiceNumberFormatted} (id=${r.id}), total ${money(r.totalCents)}.`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function materializeInvoice(subscriptionId) {
|
|
509
|
+
const r = await request('POST', `/api/BillingSubscription/${subscriptionId}/materialize`, {});
|
|
510
|
+
console.log(`Materialized ${r.invoiceNumberFormatted} (id=${r.id}), status ${r.status}.`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function upcomingInvoices(args) {
|
|
514
|
+
const rows = await request('GET', `/api/BillingSubscription/upcoming?days=${args.days || 30}`);
|
|
515
|
+
if (!rows.length) { console.log('No upcoming invoices.'); return; }
|
|
516
|
+
for (const u of rows) {
|
|
517
|
+
console.log(` [${u.kind}] sub ${u.subscriptionId} ${u.customerName} ${u.servicePeriodStart?.slice(0, 10)}..${u.servicePeriodEnd?.slice(0, 10)} sends ${u.sendAt?.slice(0, 10)} ${money(u.amountCents)} ${u.status || ''}${u.invoiceId ? ' (invoice ' + u.invoiceId + ')' : ''}${u.willSend ? '' : ' [WILL NOT SEND]'}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function copyInvoice(invoiceId, args) {
|
|
522
|
+
const body = {
|
|
523
|
+
servicePeriodStart: args.servicePeriodStart,
|
|
524
|
+
servicePeriodEnd: args.servicePeriodEnd,
|
|
525
|
+
invoiceDate: args.invoiceDate,
|
|
526
|
+
poNumber: args.poNumber,
|
|
527
|
+
};
|
|
528
|
+
const r = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/copy`, body);
|
|
529
|
+
console.log(`Invoice ${invoiceId} copied to DRAFT ${r.invoiceNumberFormatted} (id=${r.id}), total ${money(r.totalCents)}. Edit with invoice-correct, then finalize.`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function copySubscription(id, args) {
|
|
533
|
+
if (!args.name) {
|
|
534
|
+
console.error('Usage: velaro billing-subscription subscription-copy <id> --name "New customer name" [--service-start-date <date>] [--service-end-date <date>]');
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
const body = { newCustomerName: args.name, serviceStartDate: args.serviceStartDate, serviceEndDate: args.serviceEndDate };
|
|
538
|
+
const r = await request('POST', `/api/BillingSubscription/${id}/copy`, body);
|
|
539
|
+
console.log(`Subscription ${id} copied to inactive draft id=${r.id} ("${r.customerName}"). BillingEnabled=false, AutoCharge=false, no NextBillDate.`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function pauseBilling(id, args) {
|
|
543
|
+
const body = { resumeDate: args.resumeDate, invoiceBehavior: args.invoiceBehavior, reason: args.reason };
|
|
544
|
+
const r = await request('POST', `/api/BillingSubscription/${id}/pause`, body);
|
|
545
|
+
console.log(`Billing paused for subscription ${id} (behavior=${r.pauseInvoiceBehavior}, resume=${r.billingResumeDate || 'manual'}).`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function resumeBilling(id) {
|
|
549
|
+
const r = await request('POST', `/api/BillingSubscription/${id}/resume`, {});
|
|
550
|
+
console.log(`Billing resumed for subscription ${id}. Next bill date: ${r.nextBillDate || 'none'}.`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function voidInvoice(invoiceId, args) {
|
|
554
|
+
if (!args.reason) {
|
|
555
|
+
console.error('Usage: velaro billing-subscription invoice-void <invoiceId> --reason "..."');
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
const r = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/void`, { reason: args.reason });
|
|
559
|
+
console.log(`Invoice ${r.invoiceNumberFormatted} voided.`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function creditNote(invoiceId, args) {
|
|
563
|
+
if (!args.reason || (args.amountCents === undefined && !args.lines)) {
|
|
564
|
+
console.error('Usage: velaro billing-subscription credit-note <invoiceId> --reason "..." (--amount-cents <n> | --lines "<lineItemId>:<cents>,<lineItemId>:<cents>")');
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
const body = { reason: args.reason };
|
|
568
|
+
if (args.lines) {
|
|
569
|
+
body.lines = String(args.lines).split(',').map((p) => {
|
|
570
|
+
const [id, cents] = p.split(':');
|
|
571
|
+
return { lineItemId: parseInt(id, 10), amountCents: parseInt(cents, 10) };
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
body.amountCents = Number(args.amountCents);
|
|
575
|
+
}
|
|
576
|
+
const r = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/credit-note`, body);
|
|
577
|
+
const due = await request('GET', `/api/BillingSubscription/invoice/${invoiceId}/amount-due`);
|
|
578
|
+
console.log(`Credit note ${r.creditNoteNumber} (id=${r.id}) for ${money(r.amountCents)} issued. Amount due now: ${money(due.amountDueCents)}.`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function creditNoteVoid(creditNoteId, args) {
|
|
582
|
+
const r = await request('POST', `/api/BillingSubscription/credit-note/${creditNoteId}/void`, { reason: args.reason });
|
|
583
|
+
console.log(`Credit note ${r.creditNoteNumber} voided.`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function creditNotes(invoiceId) {
|
|
587
|
+
const rows = await request('GET', `/api/BillingSubscription/invoice/${invoiceId}/credit-notes`);
|
|
588
|
+
if (!rows.length) { console.log('No credit notes.'); return; }
|
|
589
|
+
for (const r of rows) console.log(`${r.id}\t${r.creditNoteNumber}\t${money(r.amountCents)}\t${r.status}\t${r.reason}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function runRenewalSweep() {
|
|
593
|
+
const result = await request('POST', '/api/BillingSubscription/renewal-sweep/run', {});
|
|
594
|
+
console.log(result.message || 'Renewal sweep triggered.');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function runDunningReport() {
|
|
598
|
+
const report = await request('POST', '/api/BillingSubscription/dunning-report/run', {});
|
|
599
|
+
if (report.length === 0) {
|
|
600
|
+
console.log('No past-due invoices.');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
console.log(`\n${report.length} past-due invoice(s):\n${'-'.repeat(80)}`);
|
|
604
|
+
for (const r of report) {
|
|
605
|
+
let flag = r.willAutoExpire ? '\x1b[31mAUTO-EXPIRING\x1b[0m' : (r.pastGracePeriod ? '\x1b[33mPAST GRACE PERIOD\x1b[0m' : 'within grace period');
|
|
606
|
+
if (r.expiryPolicy === 'NeverExpire') flag += ' NEVER-EXPIRE';
|
|
607
|
+
if (r.overrideActive) flag += ` OVERRIDE until ${String(r.overrideUntil).slice(0, 10)}`;
|
|
608
|
+
console.log(` Sub ${r.subscriptionId} (${r.customerName}) -- ${r.invoiceNumberFormatted}: ${money(r.amountOwedCents)} owed, ${r.daysPastDue}d past due (grace ${r.gracePeriodDays}d) [${flag}]`);
|
|
609
|
+
}
|
|
610
|
+
console.log('');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function revenueReport(args) {
|
|
614
|
+
const params = new URLSearchParams();
|
|
615
|
+
if (args.fromMonth) params.set('fromMonth', args.fromMonth);
|
|
616
|
+
if (args.throughMonth) params.set('throughMonth', args.throughMonth);
|
|
617
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
618
|
+
const rows = await request('GET', `/api/BillingSubscription/reports/revenue-recognition${qs}`);
|
|
619
|
+
if (rows.length === 0) {
|
|
620
|
+
console.log('No recognized revenue rows in range.');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
console.log(`\nRecognized revenue by month (excludes voided/superseded schedule rows)\n${'-'.repeat(60)}`);
|
|
624
|
+
for (const r of rows) {
|
|
625
|
+
console.log(` ${r.recognitionMonth.slice(0, 7)} ${money(r.totalCents)} (${r.rowCount} row${r.rowCount === 1 ? '' : 's'})`);
|
|
626
|
+
}
|
|
627
|
+
console.log(`\nTotal: ${money(rows.reduce((sum, r) => sum + r.totalCents, 0))}\n`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function reissueAudit() {
|
|
631
|
+
const rows = await request('GET', '/api/BillingSubscription/reports/reissues');
|
|
632
|
+
if (rows.length === 0) {
|
|
633
|
+
console.log('No reissued/superseded invoices.');
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
console.log(`\n${rows.length} reissued/superseded invoice(s):\n${'-'.repeat(80)}`);
|
|
637
|
+
for (const r of rows) {
|
|
638
|
+
const chainParts = [];
|
|
639
|
+
if (r.reissuedFromInvoiceId) chainParts.push(`reissue of invoice ${r.reissuedFromInvoiceId}`);
|
|
640
|
+
if (r.supersededByInvoiceId) chainParts.push(`superseded by invoice ${r.supersededByInvoiceId}`);
|
|
641
|
+
const chain = chainParts.length ? chainParts.join(', ') : 'no linked invoice';
|
|
642
|
+
console.log(` ${r.invoiceNumberFormatted} (id ${r.id}, sub ${r.subscriptionId}, ${r.customerName}) -- ${money(r.totalCents)} [${r.status}] -- ${chain}`);
|
|
643
|
+
if (r.internalNotes) console.log(` notes: ${r.internalNotes.split('\n').pop()}`);
|
|
644
|
+
}
|
|
645
|
+
console.log('');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function qboSync(invoiceId, args) {
|
|
649
|
+
const live = !!args.live && !args.dryRun;
|
|
650
|
+
const result = await request('POST', `/api/BillingSubscription/invoice/${invoiceId}/qbo-sync`, { live });
|
|
651
|
+
const tag = result.success ? '\x1b[32mOK\x1b[0m' : '\x1b[31mFAILED\x1b[0m';
|
|
652
|
+
console.log(`${tag} invoice ${invoiceId}: live=${result.live} outcome=${result.outcome} qboInvoiceId=${result.qboInvoiceId ?? '(none)'}`);
|
|
653
|
+
if (result.detail) console.log(` ${result.detail}`);
|
|
654
|
+
if (!args.live) console.log(' (dry-run -- pass --live to actually call the QBO client (currently a no-op, no real credentials configured))');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function anomalies() {
|
|
658
|
+
const rows = await request('GET', '/api/BillingSubscription/anomalies');
|
|
659
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
660
|
+
console.log('No flagged anomalies.');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
console.log(`\nFlagged invoices needing review (${rows.length})\n${'-'.repeat(70)}`);
|
|
664
|
+
for (const r of rows) {
|
|
665
|
+
console.log(` [invoice ${r.id}] ${r.invoiceNumberFormatted} ${r.customerName} ${money(r.totalCents)} (subscription ${r.subscriptionId})`);
|
|
666
|
+
console.log(` ${r.anomalyReason}`);
|
|
667
|
+
}
|
|
668
|
+
console.log('');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function anomalyClear(invoiceId, args) {
|
|
672
|
+
const result = await request('POST', `/api/BillingSubscription/anomaly/${invoiceId}/clear`, { note: args.note });
|
|
673
|
+
console.log(`Anomaly cleared on invoice ${result.id} by ${result.anomalyClearedBy} at ${result.anomalyClearedAt}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function generateDocument(invoiceId, args) {
|
|
677
|
+
const type = args.type;
|
|
678
|
+
if (type !== 'invoice' && type !== 'receipt' && type !== 'quote') {
|
|
679
|
+
console.error('Usage: velaro billing-subscription document <invoiceId> --type invoice|receipt|quote [--out <path>]');
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const [{ invoice, subscription }, paymentsResult] = await Promise.all([
|
|
684
|
+
request('GET', `/api/BillingSubscription/invoice/${invoiceId}`),
|
|
685
|
+
request('GET', `/api/BillingSubscription/invoice/${invoiceId}/payments`),
|
|
686
|
+
]);
|
|
687
|
+
|
|
688
|
+
// A quote's default filename must not embed the real INV- number either -- that's the exact
|
|
689
|
+
// file that would get attached/emailed to a prospect, defeating the point of the QUO- reference
|
|
690
|
+
// rendered inside the PDF itself.
|
|
691
|
+
const defaultRef = type === 'quote'
|
|
692
|
+
? `QUO-${invoice.invoiceNumber ?? invoiceId}`
|
|
693
|
+
: (invoice.invoiceNumberFormatted ?? `invoice-${invoiceId}`);
|
|
694
|
+
const outPath = args.out ?? path.resolve(process.cwd(), `${defaultRef}-${type}.pdf`);
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
await generateBillingDocument({
|
|
698
|
+
invoice,
|
|
699
|
+
subscription,
|
|
700
|
+
payments: paymentsResult.payments,
|
|
701
|
+
balance: paymentsResult.balance,
|
|
702
|
+
type,
|
|
703
|
+
outPath,
|
|
704
|
+
});
|
|
705
|
+
} catch (err) {
|
|
706
|
+
console.error(`\x1b[31mFailed to generate ${type}: ${err.message}\x1b[0m`);
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
console.log(`\x1b[32mGenerated ${type} for invoice ${invoice.invoiceNumberFormatted} -> ${outPath}\x1b[0m`);
|
|
711
|
+
}
|
|
712
|
+
|
|
231
713
|
export const billingSubscriptionCommand = {
|
|
232
714
|
command: 'billing-subscription <subcommand>',
|
|
233
715
|
describe: 'Manage standalone billing subscriptions, invoices, payments, and QBO dry-run sync status',
|
|
@@ -250,10 +732,18 @@ export const billingSubscriptionCommand = {
|
|
|
250
732
|
'net-terms': { type: 'number' },
|
|
251
733
|
'cancellation-notice-days': { type: 'number' },
|
|
252
734
|
'total-cents': { type: 'number' },
|
|
735
|
+
'recurring-amount-cents': { type: 'number', describe: 'Amount billed on EACH renewal invoice -- required before the renewal sweep will invoice this subscription' },
|
|
253
736
|
notes: { type: 'string' },
|
|
254
737
|
'enable-billing': { type: 'boolean', default: false },
|
|
255
738
|
'stripe-customer-id': { type: 'string' },
|
|
739
|
+
force: { type: 'boolean', default: false, describe: 'Bypass the duplicate-subscription guard (same customer, overlapping service period)' },
|
|
256
740
|
}, (args) => createSubscription(args))
|
|
741
|
+
.command('delete <id>', 'Permanently delete a subscription and its invoices -- refused if any invoice has a payment or was already sent/paid/voided', {
|
|
742
|
+
confirm: { type: 'boolean', default: false, describe: 'Required to actually delete -- without it, only prints what would be deleted' },
|
|
743
|
+
}, (args) => deleteSubscription(args.id, args))
|
|
744
|
+
.command('delete-invoice <invoiceId>', 'Permanently delete a single invoice -- refused if it has a payment or was already sent/paid/voided', {
|
|
745
|
+
confirm: { type: 'boolean', default: false, describe: 'Required to actually delete' },
|
|
746
|
+
}, (args) => deleteInvoice(args.invoiceId, args))
|
|
257
747
|
.command('attach-stripe-customer <id>', 'Look up (by email) or create a Stripe customer and permanently attach it to a subscription', {
|
|
258
748
|
email: { type: 'string' },
|
|
259
749
|
'stripe-customer-id': { type: 'string' },
|
|
@@ -268,6 +758,9 @@ export const billingSubscriptionCommand = {
|
|
|
268
758
|
'service-end': { type: 'string' },
|
|
269
759
|
'pre-generate': { type: 'boolean', default: false },
|
|
270
760
|
notes: { type: 'string' },
|
|
761
|
+
'number-mode': { type: 'string', default: 'sequential', describe: 'sequential | random-jitter' },
|
|
762
|
+
'number-max-jitter': { type: 'number', default: 10, describe: 'Max jitter (inclusive) when --number-mode random-jitter' },
|
|
763
|
+
'invoice-number': { type: 'number', describe: 'Explicit manual override -- bypasses --number-mode entirely' },
|
|
271
764
|
}, (args) => generateInvoice(args.id, args))
|
|
272
765
|
.command('provision-placeholder-site <id>', 'Allocate (or resume) a placeholder v20 SiteId and provision a real placeholder site under it via messaging', {
|
|
273
766
|
tier: { type: 'string', describe: 'Subscription tier (required, no default -- e.g. Starter, Professional, Enterprise)' },
|
|
@@ -282,19 +775,133 @@ export const billingSubscriptionCommand = {
|
|
|
282
775
|
reference: { type: 'string' },
|
|
283
776
|
note: { type: 'string' },
|
|
284
777
|
}, (args) => recordPayment(args.invoiceId, args))
|
|
778
|
+
.command('expiry <id>', 'Show paid-through date, expiry policy and any push-ahead override', {}, (args) => showExpiry(args.id))
|
|
779
|
+
.command('set-expiry-policy <id>', 'Set ExpiryPolicy (Default|CustomGrace|NeverExpire) and grace days (audited)', {
|
|
780
|
+
policy: { type: 'string' },
|
|
781
|
+
'grace-days': { type: 'number' },
|
|
782
|
+
reason: { type: 'string' },
|
|
783
|
+
}, (args) => setExpiryPolicy(args.id, args))
|
|
784
|
+
.command('send-invoice <invoiceId>', 'Email the invoice to the customer (idempotent; sets SentAt; --force resends)', {
|
|
785
|
+
force: { type: 'boolean', default: false },
|
|
786
|
+
}, (args) => sendInvoice(args.invoiceId, args))
|
|
787
|
+
.command('reminders <id>', 'Show or set payment reminders (--on/--off, --cadence "-7,-1,0,3,7,14" or "" for default, --email addr)', {
|
|
788
|
+
on: { type: 'boolean', default: false },
|
|
789
|
+
off: { type: 'boolean', default: false },
|
|
790
|
+
cadence: { type: 'string' },
|
|
791
|
+
email: { type: 'string' },
|
|
792
|
+
}, (args) => reminders(args.id, args))
|
|
793
|
+
.command('push-expiry <id>', 'Hold access open until a date without changing PaidThroughAt (audited; --clear removes)', {
|
|
794
|
+
until: { type: 'string' },
|
|
795
|
+
reason: { type: 'string' },
|
|
796
|
+
clear: { type: 'boolean', default: false },
|
|
797
|
+
}, (args) => pushExpiry(args.id, args))
|
|
285
798
|
.command('payments <invoiceId>', 'List payments + balance for an invoice', {}, (args) => listPayments(args.invoiceId))
|
|
286
799
|
.command('qbo-status <invoiceId>', 'Show QBO dry-run sync status for an invoice and its payments', {}, (args) => qboStatus(args.invoiceId))
|
|
800
|
+
.command('invoice-send-log <invoiceId>', 'Show the durable send/delivery audit log for an invoice -- every recipient, ACS message id, and delivered/bounced/complaint outcome', {}, (args) => invoiceSendLog(args.invoiceId))
|
|
287
801
|
.command('renewals', 'List subscriptions by upcoming renewal date, flagging who is still cancellable vs past their cancellation-notice deadline', {
|
|
288
802
|
'within-days': { type: 'number', desc: 'Only show renewals within this many days (default: all)' },
|
|
289
803
|
}, (args) => renewalsReport(args))
|
|
290
804
|
.command('set-auto-renew <id>', 'Set whether a subscription auto-renews at ServiceEndDate (data flag only -- no sweep enforces it yet)', {
|
|
291
805
|
'auto-renew': { type: 'string', describe: 'true or false' },
|
|
292
806
|
}, (args) => setAutoRenew(args.id, args))
|
|
807
|
+
.command('cancel <id>', 'Cancel a subscription: bill_more_periods | shutdown_accelerate | shutdown_keep_recognizing | shutdown_after_covered', {
|
|
808
|
+
mode: { type: 'string' }, 'extra-periods': { type: 'number' }, 'effective-date': { type: 'string' }, reason: { type: 'string' },
|
|
809
|
+
}, (args) => cancelSub(args.id, args))
|
|
810
|
+
.command('one-time-invoice <id>', 'Create a one-time invoice (setup/NRE/true-up)', {
|
|
811
|
+
description: { type: 'string' }, 'amount-cents': { type: 'number' }, date: { type: 'string' }, po: { type: 'string' },
|
|
812
|
+
}, (args) => oneTimeInvoice(args.id, args))
|
|
813
|
+
.command('mid-term-upgrade <id>', 'Mid-term upgrade prorated to the current period end (blocks flip-flop within 90 days)', {
|
|
814
|
+
'new-amount-cents': { type: 'number' }, 'effective-date': { type: 'string' }, reason: { type: 'string' },
|
|
815
|
+
}, (args) => midTermUpgrade(args.id, args))
|
|
293
816
|
.command('schedule-cadence-change <id>', 'Schedule (or cancel) a future BillingCadence change, applied by the hourly sweep once the effective date passes', {
|
|
294
817
|
cadence: { type: 'string', describe: 'annual | monthly | quarterly' },
|
|
295
818
|
'effective-at': { type: 'string', describe: 'YYYY-MM-DD the change takes effect' },
|
|
296
819
|
cancel: { type: 'boolean', default: false, describe: 'Cancel a previously-scheduled change instead of setting one' },
|
|
297
820
|
}, (args) => scheduleCadenceChange(args.id, args))
|
|
298
|
-
.
|
|
821
|
+
.command('payment-correct <paymentId>', 'Correct a field on an already-recorded payment (audited, never a silent overwrite)', {
|
|
822
|
+
field: { type: 'string', describe: 'date | amount-cents | method | reference | note' },
|
|
823
|
+
value: { type: 'string' },
|
|
824
|
+
reason: { type: 'string' },
|
|
825
|
+
}, (args) => correctPayment(args.paymentId, args))
|
|
826
|
+
.command('payment-history <paymentId>', 'Show a payment\'s live value plus every correction ever applied', {}, (args) => paymentHistory(args.paymentId))
|
|
827
|
+
.command('invoice-correct <invoiceId>', 'Correct a field on an already-generated invoice (audited, never a silent overwrite)', {
|
|
828
|
+
field: { type: 'string', describe: 'po-number | invoice-date | due-date | total-cents | internal-notes | service-period-start | service-period-end' },
|
|
829
|
+
value: { type: 'string' },
|
|
830
|
+
reason: { type: 'string' },
|
|
831
|
+
}, (args) => correctInvoice(args.invoiceId, args))
|
|
832
|
+
.command('invoice-history <invoiceId>', 'Show an invoice\'s live value plus every correction ever applied', {}, (args) => invoiceHistory(args.invoiceId))
|
|
833
|
+
.command('subscription-correct <id>', 'Correct a field on an existing subscription (audited, never a silent overwrite) -- does NOT touch any invoice already generated against the old dates', {
|
|
834
|
+
field: { type: 'string', describe: 'service-start-date|service-end-date|next-bill-date|po-number|notes|recurring-amount-cents|target-system|target-account-id|parallel-billing-mode|billing-cutover-date|external-billing-system|external-billing-account-id|auto-charge-enabled|invoice-delivery-action|invoice-recipient-emails|invoice-cc-emails|invoice-bcc-emails|contract-id|grace-period-days|auto-expire-after-grace-period|exclude-from-entitlement-sync|exclude-from-bulk-price-import' },
|
|
835
|
+
value: { type: 'string' },
|
|
836
|
+
reason: { type: 'string' },
|
|
837
|
+
clear: { type: 'boolean', default: false, describe: 'Explicitly blank out po-number/notes instead of providing --value' },
|
|
838
|
+
force: { type: 'boolean', default: false, describe: 'Bypass the customer-name overlap guard when correcting service-start-date/service-end-date' },
|
|
839
|
+
}, (args) => correctSubscription(args.id, args))
|
|
840
|
+
.command('subscription-history <id>', 'Show a subscription\'s live value plus every correction ever applied', {}, (args) => subscriptionHistory(args.id))
|
|
841
|
+
.command('invoice-reissue <invoiceId>', 'Mark an invoice superseded and generate a corrected replacement (kept for audit/historical reasons, never deleted)', {
|
|
842
|
+
reason: { type: 'string' },
|
|
843
|
+
'service-period-start': { type: 'string' },
|
|
844
|
+
'service-period-end': { type: 'string' },
|
|
845
|
+
'po-number': { type: 'string' },
|
|
846
|
+
'internal-notes': { type: 'string' },
|
|
847
|
+
}, (args) => reissueInvoice(args.invoiceId, args))
|
|
848
|
+
.command('release <invoiceId>', 'Release a pending_send_gate invoice to ready_to_send (the Activate action)', {}, (args) => releaseInvoice(args.invoiceId))
|
|
849
|
+
.command('test-fire <invoiceId>', 'Send the real invoice email to internal recipients only', {}, (args) => testFireInvoice(args.invoiceId))
|
|
850
|
+
.command('issue-now <id>', 'Issue an invoice early, dated today (id = invoice id, or subscription id with --subscription)', {
|
|
851
|
+
subscription: { type: 'boolean', default: false },
|
|
852
|
+
}, (args) => issueNow(args.id, args))
|
|
853
|
+
.command('regenerate <invoiceId>', 'Reissue an unsent invoice with totals regenerated from its line items', {}, (args) => regenerateInvoice(args.invoiceId))
|
|
854
|
+
.command('materialize <subscriptionId>', 'Create the next projected invoice for a subscription now', {}, (args) => materializeInvoice(args.subscriptionId))
|
|
855
|
+
.command('upcoming', 'List invoices due to send and projected invoices in the next N days', {
|
|
856
|
+
days: { type: 'number', default: 30 },
|
|
857
|
+
}, (args) => upcomingInvoices(args))
|
|
858
|
+
.command('invoice-copy <invoiceId>', 'Clone an invoice into a new DRAFT with a new number (no payments, PDF, or sent state copied)', {
|
|
859
|
+
'service-period-start': { type: 'string' },
|
|
860
|
+
'service-period-end': { type: 'string' },
|
|
861
|
+
'invoice-date': { type: 'string' },
|
|
862
|
+
'po-number': { type: 'string' },
|
|
863
|
+
}, (args) => copyInvoice(args.invoiceId, args))
|
|
864
|
+
.command('subscription-copy <id>', 'Clone a subscription into a new INACTIVE draft (no billing, no auto-charge, new name required)', {
|
|
865
|
+
name: { type: 'string', describe: 'New customer name (required)' },
|
|
866
|
+
'service-start-date': { type: 'string' },
|
|
867
|
+
'service-end-date': { type: 'string' },
|
|
868
|
+
}, (args) => copySubscription(args.id, args))
|
|
869
|
+
.command('pause <id>', 'Pause recurring billing. The renewal job skips it and auto-resumes at --resume-date', {
|
|
870
|
+
'resume-date': { type: 'string', describe: 'ISO date to auto-resume (optional)' },
|
|
871
|
+
'invoice-behavior': { type: 'string', describe: 'skip (default) | keep_draft | void' },
|
|
872
|
+
reason: { type: 'string' },
|
|
873
|
+
}, (args) => pauseBilling(args.id, args))
|
|
874
|
+
.command('resume <id>', 'Resume paused recurring billing now', {}, (args) => resumeBilling(args.id))
|
|
875
|
+
.command('invoice-void <invoiceId>', 'Void a finalized invoice (finalized invoices cannot be deleted, only voided)', {
|
|
876
|
+
reason: { type: 'string' },
|
|
877
|
+
}, (args) => voidInvoice(args.invoiceId, args))
|
|
878
|
+
.command('credit-note <invoiceId>', 'Issue a credit note against an invoice (total or line-level; cannot exceed invoice total)', {
|
|
879
|
+
reason: { type: 'string' },
|
|
880
|
+
'amount-cents': { type: 'number' },
|
|
881
|
+
lines: { type: 'string', describe: 'lineItemId:cents,lineItemId:cents' },
|
|
882
|
+
}, (args) => creditNote(args.invoiceId, args))
|
|
883
|
+
.command('credit-notes <invoiceId>', 'List credit notes for an invoice', {}, (args) => creditNotes(args.invoiceId))
|
|
884
|
+
.command('credit-note-void <creditNoteId>', 'Void a credit note (restores the amount due)', {
|
|
885
|
+
reason: { type: 'string' },
|
|
886
|
+
}, (args) => creditNoteVoid(args.creditNoteId, args))
|
|
887
|
+
.command('renewal-sweep', 'Manually run the recurring-invoice generator now (same code the daily Hangfire sweep calls)', {}, () => runRenewalSweep())
|
|
888
|
+
.command('dunning-report', 'Show past-due invoices and grace-period status (report-only unless a subscription has AutoExpireAfterGracePeriod enabled)', {}, () => runDunningReport())
|
|
889
|
+
.command('revenue-report', 'Portfolio-wide recognized revenue by month (built on the same revenue-schedule rows recognition-schedule reads per-invoice)', {
|
|
890
|
+
'from-month': { type: 'string', describe: 'ISO date -- only recognition months on/after this date' },
|
|
891
|
+
'through-month': { type: 'string', describe: 'ISO date -- only recognition months on/before this date' },
|
|
892
|
+
}, (args) => revenueReport({ fromMonth: args.fromMonth, throughMonth: args.throughMonth }))
|
|
893
|
+
.command('reissue-audit', 'List every invoice that has been superseded (reissued) or is itself a reissue replacement', {}, () => reissueAudit())
|
|
894
|
+
.command('qbo-sync <invoiceId>', 'Sync an invoice to QuickBooks (idempotent upsert). Dry-run unless --live is passed.', {
|
|
895
|
+
live: { type: 'boolean', default: false, describe: 'Actually call the QBO client (currently NoopQboClient -- no real credentials configured, makes no real API call)' },
|
|
896
|
+
}, (args) => qboSync(args.invoiceId, args))
|
|
897
|
+
.command('anomalies', 'List invoices currently flagged for anomaly review', {}, () => anomalies())
|
|
898
|
+
.command('anomaly-clear <invoiceId>', 'Acknowledge/clear a flagged anomaly on an invoice', {
|
|
899
|
+
note: { type: 'string' },
|
|
900
|
+
}, (args) => anomalyClear(args.invoiceId, args))
|
|
901
|
+
.command('document <invoiceId>', 'Generate a polished, on-brand invoice, receipt, or quote PDF for an invoice. --type receipt is refused unless the invoice is recorded as paid in full. --type quote renders the same invoice record without a due date/INV- number, for pre-sale use.', {
|
|
902
|
+
type: { type: 'string', describe: 'invoice | receipt | quote' },
|
|
903
|
+
out: { type: 'string', describe: 'Output file path (default: ./<InvoiceNumber>-<type>.pdf in the current directory)' },
|
|
904
|
+
}, (args) => generateDocument(args.invoiceId, args))
|
|
905
|
+
.demandCommand(1, 'Specify a subcommand: list | get | create | delete | delete-invoice | attach-stripe-customer | invoice | provision-placeholder-site | recognition-schedule | payment | payments | qbo-status | invoice-send-log | renewals | set-auto-renew | cancel | one-time-invoice | mid-term-upgrade | schedule-cadence-change | payment-correct | payment-history | invoice-correct | invoice-history | subscription-correct | subscription-history | invoice-reissue | release | test-fire | issue-now | regenerate | materialize | upcoming | renewal-sweep | dunning-report | revenue-report | reissue-audit | qbo-sync | anomalies | anomaly-clear | document | invoice-copy | subscription-copy | pause | resume | invoice-void | credit-note | credit-notes | credit-note-void | send-invoice | reminders'),
|
|
299
906
|
handler: () => {},
|
|
300
907
|
};
|