@profullstack/coinpay 0.15.0 → 0.17.0
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/CHANGELOG.md +36 -0
- package/bin/coinpay.js +196 -7
- package/package.json +1 -1
- package/src/finances-commands.js +75 -1
- package/src/finances-reports.d.ts +41 -0
- package/src/finances-reports.js +39 -0
- package/src/index.d.ts +11 -0
- package/src/index.js +12 -0
- package/src/invoices.d.ts +72 -1
- package/src/invoices.js +71 -3
- package/src/mcp.js +87 -3
- package/src/x402.js +24 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.17.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Itemised invoices: `createInvoice` / `updateInvoice` take `lineItems`
|
|
12
|
+
(description, quantity, unit price, optional tax rate) and a `discount`
|
|
13
|
+
(`percent` or `fixed`, applied before tax). The server computes the total;
|
|
14
|
+
`amount` is only for single-amount invoices.
|
|
15
|
+
- `coinpay invoice create --item "Desc|qty|price[|tax %[|tax name]]"`
|
|
16
|
+
(repeatable) and `--discount 10%` / `--discount 25`.
|
|
17
|
+
- Saved products/services and tax rates: `coinpay item list|create|delete`,
|
|
18
|
+
`coinpay tax-rate list|create|delete`; SDK `listInvoiceItems`,
|
|
19
|
+
`createInvoiceItem`, `deleteInvoiceItem`, `listTaxRates`, `createTaxRate`,
|
|
20
|
+
`deleteTaxRate`.
|
|
21
|
+
|
|
22
|
+
## [0.16.0] - 2026-10-12
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `coinpay finances due [--days 7]`: what is due, overdue or not posted yet
|
|
26
|
+
across every linked account, projected from each recurring charge's own
|
|
27
|
+
cadence plus statement due days set on cards and loans.
|
|
28
|
+
- `coinpay finances recurring`: every recurring charge with cadence, last and
|
|
29
|
+
next date and monthly cost.
|
|
30
|
+
- `coinpay finances due-day <account-id> <1-31|clear> [--amount N|balance]`:
|
|
31
|
+
a card or loan's statement due day, which no bank feed carries.
|
|
32
|
+
- `coinpay finances daily [show|on|off|send-now]`: the daily summary email
|
|
33
|
+
(due soon, new activity since the last one, balance sheet).
|
|
34
|
+
- MCP: `finance_due_soon`, `finance_recurring`, `finance_summary`,
|
|
35
|
+
`finance_transactions`, `finance_set_due_day`, `finance_daily_summary`.
|
|
36
|
+
- SDK: `getDueSoon`, `getDailySummary`, `setDailySummary`,
|
|
37
|
+
`deleteDailySummary`, `sendDailySummaryNow`, `setAccountDueDay`.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- `coinpay mcp` `tools/list` now carries `resultType`, `ttlMs` and
|
|
41
|
+
`cacheScope`; without them Claude Code rejected the whole list
|
|
42
|
+
("connected · tools fetch failed").
|
|
43
|
+
|
|
8
44
|
## [0.15.0] - 2026-10-07
|
|
9
45
|
|
|
10
46
|
### Added
|
package/bin/coinpay.js
CHANGED
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
sendInvoice,
|
|
25
25
|
updateInvoice,
|
|
26
26
|
InvoiceStatus,
|
|
27
|
+
listTaxRates,
|
|
28
|
+
createTaxRate,
|
|
29
|
+
deleteTaxRate,
|
|
30
|
+
listInvoiceItems,
|
|
31
|
+
createInvoiceItem,
|
|
32
|
+
deleteInvoiceItem,
|
|
27
33
|
} from '../src/invoices.js';
|
|
28
34
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'fs';
|
|
29
35
|
import { execSync, spawn } from 'child_process';
|
|
@@ -206,8 +212,18 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
206
212
|
/**
|
|
207
213
|
* Parse command line arguments
|
|
208
214
|
*/
|
|
215
|
+
// Flags that may be given more than once collect into an array.
|
|
216
|
+
const REPEATABLE_FLAGS = new Set(['item']);
|
|
217
|
+
|
|
209
218
|
function parseArgs(args) {
|
|
210
219
|
const result = { command: null, subcommand: null, args: [], flags: {} };
|
|
220
|
+
const setFlag = (name, value) => {
|
|
221
|
+
if (REPEATABLE_FLAGS.has(name) && value !== true) {
|
|
222
|
+
result.flags[name] = [...(Array.isArray(result.flags[name]) ? result.flags[name] : []), value];
|
|
223
|
+
} else {
|
|
224
|
+
result.flags[name] = value;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
211
227
|
|
|
212
228
|
for (let i = 0; i < args.length; i++) {
|
|
213
229
|
const arg = args[i];
|
|
@@ -215,14 +231,14 @@ function parseArgs(args) {
|
|
|
215
231
|
if (arg.startsWith('--')) {
|
|
216
232
|
const eqIdx = arg.indexOf('=');
|
|
217
233
|
if (eqIdx !== -1) {
|
|
218
|
-
|
|
234
|
+
setFlag(arg.slice(2, eqIdx), arg.slice(eqIdx + 1));
|
|
219
235
|
} else {
|
|
220
236
|
const name = arg.slice(2);
|
|
221
237
|
const next = args[i + 1];
|
|
222
238
|
if (BOOLEAN_FLAGS.has(name)) {
|
|
223
239
|
result.flags[name] = true;
|
|
224
240
|
} else if (next && !next.startsWith('-')) {
|
|
225
|
-
|
|
241
|
+
setFlag(name, next);
|
|
226
242
|
i++;
|
|
227
243
|
} else {
|
|
228
244
|
result.flags[name] = true;
|
|
@@ -289,6 +305,40 @@ function isValidDate(value) {
|
|
|
289
305
|
);
|
|
290
306
|
}
|
|
291
307
|
|
|
308
|
+
function parseItemFlags(values) {
|
|
309
|
+
return values.map((value) => {
|
|
310
|
+
const [description, qty, price, tax, taxName] = String(value).split('|').map((p) => p.trim());
|
|
311
|
+
const quantity = Number(qty);
|
|
312
|
+
const unitPrice = parseNonNegativeAmount(price);
|
|
313
|
+
const taxRate = tax ? Number(tax) : null;
|
|
314
|
+
if (!description || !(quantity > 0) || unitPrice === null || (tax && !(taxRate >= 0 && taxRate <= 100))) {
|
|
315
|
+
print.error('--item must look like "Description|qty|unit price[|tax %[|tax name]]", got: ' + value);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
description,
|
|
320
|
+
quantity,
|
|
321
|
+
unit_price: unitPrice,
|
|
322
|
+
...(taxRate ? { tax_rate: taxRate, tax_name: taxName || null } : {}),
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseNonNegativeAmount(value) {
|
|
328
|
+
if (typeof value !== 'string' || !/^\d+(\.\d{1,2})?$/.test(value)) return null;
|
|
329
|
+
return Number(value);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** "10%" is a percent discount, "25" a fixed one in the invoice currency. */
|
|
333
|
+
function parseDiscountFlag(value) {
|
|
334
|
+
const match = /^(\d+(?:\.\d+)?)(%?)$/.exec(String(value).trim());
|
|
335
|
+
if (!match || !(Number(match[1]) > 0)) {
|
|
336
|
+
print.error('--discount must be a percent like 10% or a fixed amount like 25');
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
return { type: match[2] ? 'percent' : 'fixed', value: Number(match[1]) };
|
|
340
|
+
}
|
|
341
|
+
|
|
292
342
|
function requireFlagValues(flags, names) {
|
|
293
343
|
for (const name of names) {
|
|
294
344
|
if (flags[name] === true) {
|
|
@@ -325,6 +375,10 @@ function invoiceForOutput(data, fallbackId) {
|
|
|
325
375
|
clientId: invoice.clientId ?? invoice.client_id,
|
|
326
376
|
currency: invoice.currency,
|
|
327
377
|
amount: invoice.amount,
|
|
378
|
+
lineItems: invoice.lineItems ?? invoice.line_items ?? null,
|
|
379
|
+
subtotal: invoice.subtotal ?? null,
|
|
380
|
+
discountTotal: invoice.discountTotal ?? invoice.discount_total ?? null,
|
|
381
|
+
taxTotal: invoice.taxTotal ?? invoice.tax_total ?? null,
|
|
328
382
|
cryptoCurrency: invoice.cryptoCurrency ?? invoice.crypto_currency,
|
|
329
383
|
cryptoAmount: invoice.cryptoAmount ?? invoice.crypto_amount,
|
|
330
384
|
status: invoice.status,
|
|
@@ -403,7 +457,9 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
403
457
|
qr <id> Get payment QR code
|
|
404
458
|
|
|
405
459
|
${colors.bright}invoice${colors.reset}
|
|
406
|
-
create Create a draft invoice (--amount
|
|
460
|
+
create Create a draft invoice (--amount, or one or more
|
|
461
|
+
--item "Desc|qty|price[|tax %[|tax name]]" and
|
|
462
|
+
an optional --discount 10% / --discount 25)
|
|
407
463
|
--wallet-id <id> Route settlement through a saved wallet
|
|
408
464
|
--merchant-wallet-address <address>
|
|
409
465
|
Route settlement to a direct address
|
|
@@ -415,6 +471,17 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
415
471
|
delete <id> Permanently delete a draft invoice
|
|
416
472
|
--yes Skip confirmation for publish/send/delete
|
|
417
473
|
|
|
474
|
+
${colors.bright}item${colors.reset}
|
|
475
|
+
list Saved products/services (--business-id)
|
|
476
|
+
create Save one: --name, --price [--unit hour] [--description]
|
|
477
|
+
[--tax-rate-id <id>]
|
|
478
|
+
delete <id> Delete a saved item (issued invoices keep their copy)
|
|
479
|
+
|
|
480
|
+
${colors.bright}tax-rate${colors.reset}
|
|
481
|
+
list Saved tax rates (--business-id)
|
|
482
|
+
create Save one: --name "CA Sales Tax" --rate 8.25 [--default]
|
|
483
|
+
delete <id> Delete a saved tax rate
|
|
484
|
+
|
|
418
485
|
${colors.bright}tokens${colors.reset}
|
|
419
486
|
list List checkout tokens (--business-id, --active-only)
|
|
420
487
|
|
|
@@ -529,6 +596,10 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
529
596
|
--with-documents attaches the period's tax documents within
|
|
530
597
|
8 MiB; only to your own address)
|
|
531
598
|
digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
|
|
599
|
+
due [--days 7] What is due and overdue across every account (--days up to 60)
|
|
600
|
+
recurring Every recurring charge detected, with next date and monthly cost
|
|
601
|
+
due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
|
|
602
|
+
daily [show|on|off|send-now] Daily summary email: due soon, new activity, position (--hour 7 --to …)
|
|
532
603
|
|
|
533
604
|
${colors.bright}escrow${colors.reset}
|
|
534
605
|
create Create a new escrow
|
|
@@ -1064,6 +1135,15 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
|
|
|
1064
1135
|
console.log('\n' + colors.bright + label + colors.reset);
|
|
1065
1136
|
print.info('ID: ' + invoice.id);
|
|
1066
1137
|
print.info('Status: ' + (invoice.status || 'unknown'));
|
|
1138
|
+
if (Array.isArray(invoice.lineItems) && invoice.lineItems.length > 0) {
|
|
1139
|
+
for (const line of invoice.lineItems) {
|
|
1140
|
+
const tax = line.tax_rate ? ' +' + line.tax_rate + '% ' + (line.tax_name || 'tax') : '';
|
|
1141
|
+
print.info(' ' + line.description.split('\n')[0] + ': ' + line.quantity + ' x ' + line.unit_price + ' = ' + line.amount + tax);
|
|
1142
|
+
}
|
|
1143
|
+
print.info('Subtotal: ' + invoice.subtotal);
|
|
1144
|
+
if (Number(invoice.discountTotal) > 0) print.info('Discount: -' + invoice.discountTotal);
|
|
1145
|
+
if (Number(invoice.taxTotal) > 0) print.info('Tax: ' + invoice.taxTotal);
|
|
1146
|
+
}
|
|
1067
1147
|
print.info('Amount: ' + invoice.amount + ' ' + (invoice.currency || ''));
|
|
1068
1148
|
if (invoice.businessId) print.info('Business: ' + invoice.businessId);
|
|
1069
1149
|
if (invoice.clientId) print.info('Client: ' + invoice.clientId);
|
|
@@ -1084,6 +1164,92 @@ function printInvoiceDetails(invoice, { shareUrl } = {}) {
|
|
|
1084
1164
|
/**
|
|
1085
1165
|
* Invoice commands
|
|
1086
1166
|
*/
|
|
1167
|
+
async function handleCatalog(kind, subcommand, args, flags) {
|
|
1168
|
+
const client = createClient();
|
|
1169
|
+
const isItem = kind === 'item';
|
|
1170
|
+
requireFlagValues(flags, ['business-id', 'name', 'price', 'unit', 'description', 'tax-rate-id', 'rate']);
|
|
1171
|
+
|
|
1172
|
+
switch (subcommand) {
|
|
1173
|
+
case 'list': {
|
|
1174
|
+
const rows = isItem
|
|
1175
|
+
? await listInvoiceItems(client, { businessId: flags['business-id'] })
|
|
1176
|
+
: await listTaxRates(client, { businessId: flags['business-id'] });
|
|
1177
|
+
if (flags.json) {
|
|
1178
|
+
print.json(rows);
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
if (rows.length === 0) {
|
|
1182
|
+
print.info(isItem ? 'No saved items' : 'No saved tax rates');
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
for (const row of rows) {
|
|
1186
|
+
print.info(
|
|
1187
|
+
isItem
|
|
1188
|
+
? row.id + ' ' + row.name + ' ' + row.unit_price + (row.unit ? ' / ' + row.unit : '')
|
|
1189
|
+
: row.id + ' ' + row.name + ' ' + row.rate + '%' + (row.is_default ? ' (default)' : '')
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
break;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
case 'create': {
|
|
1196
|
+
if (!flags.name) {
|
|
1197
|
+
print.error('--name is required');
|
|
1198
|
+
process.exit(1);
|
|
1199
|
+
}
|
|
1200
|
+
let row;
|
|
1201
|
+
if (isItem) {
|
|
1202
|
+
const unitPrice = parseNonNegativeAmount(flags.price);
|
|
1203
|
+
if (unitPrice === null) {
|
|
1204
|
+
print.error('--price must be a decimal with at most two decimal places');
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
row = await createInvoiceItem(client, {
|
|
1208
|
+
businessId: flags['business-id'],
|
|
1209
|
+
name: flags.name,
|
|
1210
|
+
unitPrice,
|
|
1211
|
+
description: flags.description,
|
|
1212
|
+
unit: flags.unit,
|
|
1213
|
+
taxRateId: flags['tax-rate-id'],
|
|
1214
|
+
});
|
|
1215
|
+
} else {
|
|
1216
|
+
const rate = Number(flags.rate);
|
|
1217
|
+
if (flags.rate === undefined || !(rate >= 0 && rate <= 100)) {
|
|
1218
|
+
print.error('--rate must be a percent between 0 and 100');
|
|
1219
|
+
process.exit(1);
|
|
1220
|
+
}
|
|
1221
|
+
row = await createTaxRate(client, {
|
|
1222
|
+
businessId: flags['business-id'],
|
|
1223
|
+
name: flags.name,
|
|
1224
|
+
rate,
|
|
1225
|
+
isDefault: flags.default === true || flags.default === 'true',
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
if (flags.json) {
|
|
1229
|
+
print.json(row);
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
print.success((isItem ? 'Item' : 'Tax rate') + ' saved: ' + row.id);
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
case 'delete': {
|
|
1237
|
+
if (args.length !== 1) {
|
|
1238
|
+
print.error('Usage: coinpay ' + kind + ' delete <id>');
|
|
1239
|
+
process.exit(1);
|
|
1240
|
+
}
|
|
1241
|
+
if (isItem) await deleteInvoiceItem(client, args[0]);
|
|
1242
|
+
else await deleteTaxRate(client, args[0]);
|
|
1243
|
+
print.success('Deleted ' + args[0]);
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
default:
|
|
1248
|
+
print.error('Usage: coinpay ' + kind + ' <list|create|delete>');
|
|
1249
|
+
process.exit(1);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1087
1253
|
async function handleInvoice(subcommand, args, flags) {
|
|
1088
1254
|
const client = createClient();
|
|
1089
1255
|
|
|
@@ -1099,12 +1265,23 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1099
1265
|
'notes',
|
|
1100
1266
|
'wallet-id',
|
|
1101
1267
|
'merchant-wallet-address',
|
|
1268
|
+
'item',
|
|
1269
|
+
'discount',
|
|
1102
1270
|
]);
|
|
1103
1271
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1272
|
+
// --item "Description|qty|unit price[|tax %[|tax name]]", repeatable.
|
|
1273
|
+
const lineItems = flags.item === undefined ? undefined : parseItemFlags(flags.item);
|
|
1274
|
+
const discount = flags.discount === undefined ? undefined : parseDiscountFlag(flags.discount);
|
|
1275
|
+
if (discount && !lineItems) {
|
|
1276
|
+
print.error('--discount applies to itemised invoices; add one or more --item');
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
const amount = lineItems ? undefined : parsePositiveAmount(flags.amount);
|
|
1281
|
+
if (!lineItems && amount === null) {
|
|
1106
1282
|
print.error('--amount must be a positive decimal with at most two decimal places');
|
|
1107
1283
|
print.info('Example: coinpay invoice create --amount 125 --currency USD --notes "Consulting"');
|
|
1284
|
+
print.info('Or itemise: coinpay invoice create --item "Design|3|100" --item "Hosting|1|19.99|8.25|CA tax"');
|
|
1108
1285
|
process.exit(1);
|
|
1109
1286
|
}
|
|
1110
1287
|
|
|
@@ -1126,6 +1303,8 @@ async function handleInvoice(subcommand, args, flags) {
|
|
|
1126
1303
|
clientId: flags['client-id'],
|
|
1127
1304
|
currency: currency.toUpperCase(),
|
|
1128
1305
|
amount,
|
|
1306
|
+
lineItems,
|
|
1307
|
+
discount,
|
|
1129
1308
|
cryptoCurrency: cryptoCurrency?.toUpperCase(),
|
|
1130
1309
|
dueDate,
|
|
1131
1310
|
notes: flags.notes,
|
|
@@ -4355,7 +4534,12 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4355
4534
|
case 'statements':
|
|
4356
4535
|
case 'books':
|
|
4357
4536
|
case 'payloads':
|
|
4358
|
-
case 'digest':
|
|
4537
|
+
case 'digest':
|
|
4538
|
+
case 'due':
|
|
4539
|
+
case 'due-soon':
|
|
4540
|
+
case 'recurring':
|
|
4541
|
+
case 'due-day':
|
|
4542
|
+
case 'daily': {
|
|
4359
4543
|
const { client } = financesClient();
|
|
4360
4544
|
const { runFinancesCommand } = await import('../src/finances-commands.js');
|
|
4361
4545
|
const code = await runFinancesCommand(subcommand, args, flags, {
|
|
@@ -4369,7 +4553,7 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4369
4553
|
|
|
4370
4554
|
default:
|
|
4371
4555
|
print.error(`Unknown finances command: ${subcommand}`);
|
|
4372
|
-
console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest]');
|
|
4556
|
+
console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest|due|recurring|due-day|daily]');
|
|
4373
4557
|
process.exit(1);
|
|
4374
4558
|
}
|
|
4375
4559
|
}
|
|
@@ -4580,6 +4764,11 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4580
4764
|
await handleInvoice(subcommand, args, flags);
|
|
4581
4765
|
break;
|
|
4582
4766
|
|
|
4767
|
+
case 'item':
|
|
4768
|
+
case 'tax-rate':
|
|
4769
|
+
await handleCatalog(command, subcommand, args, flags);
|
|
4770
|
+
break;
|
|
4771
|
+
|
|
4583
4772
|
case 'tokens':
|
|
4584
4773
|
await handleTokens(subcommand, args, flags);
|
|
4585
4774
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/finances-commands.js
CHANGED
|
@@ -846,6 +846,80 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
846
846
|
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances digest [show|set --days mon,fri --hour 8 --to a@x,b@y [--scope business]|off|delete|send-now]');
|
|
847
847
|
}
|
|
848
848
|
|
|
849
|
+
case 'due':
|
|
850
|
+
case 'due-soon':
|
|
851
|
+
case 'recurring': {
|
|
852
|
+
const days = flags.days !== undefined ? Number(flags.days) : undefined;
|
|
853
|
+
if (days !== undefined && (!Number.isInteger(days) || days < 0 || days > 60)) throw new CliExit(EXIT.INVALID, '--days must be 0 to 60');
|
|
854
|
+
const d = await api.getDueSoon(client, { days, timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined });
|
|
855
|
+
const cur = d.currency || 'USD';
|
|
856
|
+
const money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n);
|
|
857
|
+
if (subcommand === 'recurring') {
|
|
858
|
+
if (json) { out(JSON.stringify(d.recurring, null, 2)); return EXIT.OK; }
|
|
859
|
+
const rows = [...d.recurring.charges].sort((a, b) => a.nextExpected.localeCompare(b.nextExpected));
|
|
860
|
+
out(`${rows.length} recurring charges · ${money(d.recurring.monthlyTotal)}/mo${d.recurring.monthlyDebtService ? ` (${money(d.recurring.monthlyDebtService)} debt service)` : ''}`);
|
|
861
|
+
for (const c of rows) {
|
|
862
|
+
out(` next ${c.nextExpected.slice(0, 10)} ${c.cadence.padEnd(9)} ${money(c.amount).padStart(11)} ${c.payee} · ${c.accountName}${c.isDebtService ? ' · debt' : ''}`);
|
|
863
|
+
}
|
|
864
|
+
return EXIT.OK;
|
|
865
|
+
}
|
|
866
|
+
if (json) { out(JSON.stringify(d, null, 2)); return EXIT.OK; }
|
|
867
|
+
const when = (i) => i.status === 'paid' ? 'paid' : i.status === 'awaiting' ? 'not yet?' : i.daysUntil === 0 ? 'today' : i.daysUntil === 1 ? 'tomorrow' : i.daysUntil < 0 ? `${-i.daysUntil}d LATE` : `in ${i.daysUntil}d`;
|
|
868
|
+
const amt = (i) => i.amount !== null ? money(i.amount) : i.owed !== null ? `bal ${money(i.owed)}` : 'statement';
|
|
869
|
+
out(`${d.today} (${d.timezone}) · ${money(d.totals.due)} due in ${d.horizonDays} days${d.totals.overdue ? ` · ${money(d.totals.overdue)} OVERDUE` : ''}${d.totals.unpriced ? ` · +${d.totals.unpriced} statement balance(s)` : ''}`);
|
|
870
|
+
if (d.items.length === 0 && d.paid.length === 0) out(' Nothing due.');
|
|
871
|
+
for (const i of [...d.items, ...d.paid]) {
|
|
872
|
+
out(` ${i.dueOn} ${when(i).padEnd(9)} ${amt(i).padStart(14)} ${i.payee} · ${i.accountName}${i.source === 'scheduled' ? ' · statement due' : ''}`);
|
|
873
|
+
}
|
|
874
|
+
if (d.missed.length) out(` Not seen lately: ${d.missed.slice(0, 8).map((m) => `${m.payee} (expected ${m.expectedOn})`).join(', ')}`);
|
|
875
|
+
return EXIT.OK;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
case 'due-day': {
|
|
879
|
+
const id = args[0];
|
|
880
|
+
const value = args[1];
|
|
881
|
+
if (!id || value === undefined) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances due-day <account-id> <1-31|clear> [--amount 810.46|--amount balance] (ids: coinpay finances accounts)');
|
|
882
|
+
const day = value === 'clear' || value === 'off' ? null : Number(value);
|
|
883
|
+
if (day !== null && (!Number.isInteger(day) || day < 1 || day > 31)) throw new CliExit(EXIT.INVALID, 'The due day must be 1 to 31, or clear');
|
|
884
|
+
const amount = flags.amount === undefined ? undefined : flags.amount === 'balance' || flags.amount === true ? null : String(flags.amount);
|
|
885
|
+
const account = await api.setAccountDueDay(client, id, { day, amount });
|
|
886
|
+
emit({ account }, day === null ? `Cleared the due day on ${account.name}.` : `${account.name}: due on day ${day}${account.payment_due_amount != null ? `, ${account.payment_due_amount}` : ', statement balance'}.`);
|
|
887
|
+
return EXIT.OK;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
case 'daily': {
|
|
891
|
+
const action = flags.off ? 'off' : args[0] || 'show';
|
|
892
|
+
const describe = (s) => s
|
|
893
|
+
? `Daily summary ${s.active ? 'on' : 'off'} · ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
|
|
894
|
+
: 'No daily summary configured. Turn it on with: coinpay finances daily on --hour 7 --to you@example.com';
|
|
895
|
+
if (action === 'show') {
|
|
896
|
+
const s = await api.getDailySummary(client);
|
|
897
|
+
emit({ schedule: s }, describe(s));
|
|
898
|
+
return EXIT.OK;
|
|
899
|
+
}
|
|
900
|
+
if (action === 'on' || action === 'set') {
|
|
901
|
+
const s = await api.setDailySummary(client, {
|
|
902
|
+
hour: flags.hour !== undefined ? Number(flags.hour) : undefined,
|
|
903
|
+
timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined,
|
|
904
|
+
recipients: flags.to ? listFlag(flags.to) : undefined,
|
|
905
|
+
active: true,
|
|
906
|
+
});
|
|
907
|
+
emit({ schedule: s }, describe(s));
|
|
908
|
+
return EXIT.OK;
|
|
909
|
+
}
|
|
910
|
+
if (action === 'off' || action === 'delete') {
|
|
911
|
+
const data = await api.deleteDailySummary(client);
|
|
912
|
+
emit(data, 'Daily summary turned off.');
|
|
913
|
+
return EXIT.OK;
|
|
914
|
+
}
|
|
915
|
+
if (action === 'send-now') {
|
|
916
|
+
const data = await api.sendDailySummaryNow(client);
|
|
917
|
+
emit(data, `Sent to ${data.sent.join(', ') || 'nobody'} (${data.due} due, ${data.overdue} overdue)${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
|
|
918
|
+
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
919
|
+
}
|
|
920
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances daily [show|on --hour 7 --to a@x|off|send-now]');
|
|
921
|
+
}
|
|
922
|
+
|
|
849
923
|
case 'payloads': {
|
|
850
924
|
const action = args[0] || 'list';
|
|
851
925
|
if (action === 'list') {
|
|
@@ -881,4 +955,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
881
955
|
}
|
|
882
956
|
}
|
|
883
957
|
|
|
884
|
-
export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'payloads', 'digest'];
|
|
958
|
+
export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'payloads', 'digest', 'due', 'due-soon', 'recurring', 'due-day', 'daily'];
|
|
@@ -395,3 +395,44 @@ export function setWeeklyDigest(
|
|
|
395
395
|
): Promise<WeeklyDigest>;
|
|
396
396
|
export function deleteWeeklyDigest(client: CoinPayClient): Promise<{ success: boolean }>;
|
|
397
397
|
export function sendWeeklyDigestNow(client: CoinPayClient): Promise<SendOutcome>;
|
|
398
|
+
|
|
399
|
+
export interface DueItem {
|
|
400
|
+
source: 'projected' | 'scheduled';
|
|
401
|
+
payee: string;
|
|
402
|
+
amount: number | null;
|
|
403
|
+
owed: number | null;
|
|
404
|
+
cadence: string;
|
|
405
|
+
dueOn: string;
|
|
406
|
+
daysUntil: number;
|
|
407
|
+
status: 'overdue' | 'awaiting' | 'today' | 'soon' | 'paid';
|
|
408
|
+
accountId: string;
|
|
409
|
+
accountName: string;
|
|
410
|
+
scope: 'business' | 'personal';
|
|
411
|
+
isDebtService: boolean;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface DueSoon {
|
|
415
|
+
timezone: string;
|
|
416
|
+
today: string;
|
|
417
|
+
horizonDays: number;
|
|
418
|
+
items: DueItem[];
|
|
419
|
+
paid: DueItem[];
|
|
420
|
+
missed: Array<{ payee: string; amount: number; expectedOn: string; lastSeen: string; accountName: string }>;
|
|
421
|
+
totals: { due: number; overdue: number; unpriced: number };
|
|
422
|
+
recurring: {
|
|
423
|
+
charges: Array<{ payee: string; amount: number; cadence: string; occurrences: number; lastSeen: string; nextExpected: string; monthlyEquivalent: number; scope: 'business' | 'personal'; isDebtService: boolean; accountId: string; accountName: string }>;
|
|
424
|
+
monthlyTotal: number;
|
|
425
|
+
monthlyDebtService: number;
|
|
426
|
+
};
|
|
427
|
+
currency: string | null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function getDueSoon(client: CoinPayClient, opts?: { days?: number; timezone?: string }): Promise<DueSoon>;
|
|
431
|
+
export function getDailySummary(client: CoinPayClient): Promise<WeeklyDigest | null>;
|
|
432
|
+
export function setDailySummary(
|
|
433
|
+
client: CoinPayClient,
|
|
434
|
+
opts?: { weekdays?: number[]; hour?: number; timezone?: string; recipients?: string[]; active?: boolean },
|
|
435
|
+
): Promise<WeeklyDigest>;
|
|
436
|
+
export function deleteDailySummary(client: CoinPayClient): Promise<{ success: boolean }>;
|
|
437
|
+
export function sendDailySummaryNow(client: CoinPayClient): Promise<{ sent: string[]; failed: Array<{ to: string; error: string }>; due: number; overdue: number }>;
|
|
438
|
+
export function setAccountDueDay(client: CoinPayClient, accountId: string, opts: { day: number | null; amount?: number | string | null }): Promise<Record<string, unknown>>;
|
package/src/finances-reports.js
CHANGED
|
@@ -445,3 +445,42 @@ export async function deleteWeeklyDigest(client) {
|
|
|
445
445
|
export async function sendWeeklyDigestNow(client) {
|
|
446
446
|
return call(client, '/finances/email-schedules/send-now', { method: 'POST', body: '{}' });
|
|
447
447
|
}
|
|
448
|
+
|
|
449
|
+
/** Charges due across every account: projected recurring charges plus statement due days. */
|
|
450
|
+
export async function getDueSoon(client, { days, timezone } = {}) {
|
|
451
|
+
const params = new URLSearchParams();
|
|
452
|
+
if (days !== undefined) params.set('days', String(days));
|
|
453
|
+
if (timezone) params.set('timezone', timezone);
|
|
454
|
+
const qs = params.toString();
|
|
455
|
+
const data = await call(client, `/finances/due-soon${qs ? `?${qs}` : ''}`);
|
|
456
|
+
return data.dueSoon;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function getDailySummary(client) {
|
|
460
|
+
const data = await call(client, '/finances/email-schedules?kind=daily_summary');
|
|
461
|
+
return data.schedule;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function setDailySummary(client, { weekdays, hour, timezone, recipients, active } = {}) {
|
|
465
|
+
const data = await call(client, '/finances/email-schedules', {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
body: JSON.stringify({ kind: 'daily_summary', weekdays, hour, timezone, recipients, active }),
|
|
468
|
+
});
|
|
469
|
+
return data.schedule;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function deleteDailySummary(client) {
|
|
473
|
+
return call(client, '/finances/email-schedules?kind=daily_summary', { method: 'DELETE' });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function sendDailySummaryNow(client) {
|
|
477
|
+
return call(client, '/finances/email-schedules/send-now?kind=daily_summary', { method: 'POST', body: '{}' });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Set (or clear with day null) a card or loan's statement due day and fixed amount. */
|
|
481
|
+
export async function setAccountDueDay(client, accountId, { day, amount } = {}) {
|
|
482
|
+
const body = { payment_due_day: day ?? null };
|
|
483
|
+
if (amount !== undefined) body.payment_due_amount = amount;
|
|
484
|
+
const data = await call(client, `/finances/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
485
|
+
return data.account;
|
|
486
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -55,9 +55,20 @@ export {
|
|
|
55
55
|
sendInvoice,
|
|
56
56
|
getInvoicePaymentData,
|
|
57
57
|
InvoiceStatus,
|
|
58
|
+
listTaxRates,
|
|
59
|
+
createTaxRate,
|
|
60
|
+
deleteTaxRate,
|
|
61
|
+
listInvoiceItems,
|
|
62
|
+
createInvoiceItem,
|
|
63
|
+
deleteInvoiceItem,
|
|
58
64
|
} from './invoices.js';
|
|
59
65
|
export type {
|
|
60
66
|
Invoice,
|
|
67
|
+
InvoiceLine,
|
|
68
|
+
InvoiceLineInput,
|
|
69
|
+
InvoiceDiscount,
|
|
70
|
+
TaxRate,
|
|
71
|
+
InvoiceItem,
|
|
61
72
|
InvoiceStatusType,
|
|
62
73
|
CreateInvoiceParams,
|
|
63
74
|
ListInvoicesParams,
|
package/src/index.js
CHANGED
|
@@ -82,6 +82,12 @@ import {
|
|
|
82
82
|
sendInvoice,
|
|
83
83
|
getInvoicePaymentData,
|
|
84
84
|
InvoiceStatus,
|
|
85
|
+
listTaxRates,
|
|
86
|
+
createTaxRate,
|
|
87
|
+
deleteTaxRate,
|
|
88
|
+
listInvoiceItems,
|
|
89
|
+
createInvoiceItem,
|
|
90
|
+
deleteInvoiceItem,
|
|
85
91
|
} from './invoices.js';
|
|
86
92
|
|
|
87
93
|
// Wallet exports
|
|
@@ -231,6 +237,12 @@ export {
|
|
|
231
237
|
sendInvoice,
|
|
232
238
|
getInvoicePaymentData,
|
|
233
239
|
InvoiceStatus,
|
|
240
|
+
listTaxRates,
|
|
241
|
+
createTaxRate,
|
|
242
|
+
deleteTaxRate,
|
|
243
|
+
listInvoiceItems,
|
|
244
|
+
createInvoiceItem,
|
|
245
|
+
deleteInvoiceItem,
|
|
234
246
|
|
|
235
247
|
// Wallet
|
|
236
248
|
WalletClient,
|
package/src/invoices.d.ts
CHANGED
|
@@ -10,6 +10,27 @@ export const InvoiceStatus: {
|
|
|
10
10
|
|
|
11
11
|
export type InvoiceStatusType = (typeof InvoiceStatus)[keyof typeof InvoiceStatus];
|
|
12
12
|
|
|
13
|
+
export interface InvoiceLineInput {
|
|
14
|
+
description: string;
|
|
15
|
+
quantity: number;
|
|
16
|
+
unit_price: number;
|
|
17
|
+
tax_rate?: number | null;
|
|
18
|
+
tax_name?: string | null;
|
|
19
|
+
item_id?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InvoiceLine extends Required<InvoiceLineInput> {
|
|
23
|
+
/** quantity x unit_price, before discount and tax */
|
|
24
|
+
amount: number;
|
|
25
|
+
discount: number;
|
|
26
|
+
tax: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InvoiceDiscount {
|
|
30
|
+
type: 'percent' | 'fixed';
|
|
31
|
+
value: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export interface Invoice {
|
|
14
35
|
id: string;
|
|
15
36
|
invoiceNumber?: string;
|
|
@@ -17,6 +38,11 @@ export interface Invoice {
|
|
|
17
38
|
clientId?: string;
|
|
18
39
|
currency?: string;
|
|
19
40
|
amount?: number;
|
|
41
|
+
lineItems?: InvoiceLine[] | null;
|
|
42
|
+
subtotal?: number | null;
|
|
43
|
+
discount?: InvoiceDiscount | null;
|
|
44
|
+
discountTotal?: number | null;
|
|
45
|
+
taxTotal?: number | null;
|
|
20
46
|
cryptoCurrency?: string;
|
|
21
47
|
cryptoAmount?: string | number;
|
|
22
48
|
status?: InvoiceStatusType;
|
|
@@ -36,7 +62,10 @@ export interface CreateInvoiceParams {
|
|
|
36
62
|
businessId?: string;
|
|
37
63
|
clientId?: string;
|
|
38
64
|
currency: string;
|
|
39
|
-
|
|
65
|
+
/** Required unless lineItems is given; the server computes itemised totals. */
|
|
66
|
+
amount?: number;
|
|
67
|
+
lineItems?: InvoiceLineInput[];
|
|
68
|
+
discount?: InvoiceDiscount;
|
|
40
69
|
cryptoCurrency?: string;
|
|
41
70
|
dueDate?: string;
|
|
42
71
|
notes?: string;
|
|
@@ -54,6 +83,9 @@ export interface ListInvoicesParams {
|
|
|
54
83
|
|
|
55
84
|
export interface UpdateInvoiceParams {
|
|
56
85
|
amount?: number;
|
|
86
|
+
/** Replace the lines; null turns itemising off. */
|
|
87
|
+
lineItems?: InvoiceLineInput[] | null;
|
|
88
|
+
discount?: InvoiceDiscount | null;
|
|
57
89
|
currency?: string;
|
|
58
90
|
cryptoCurrency?: string;
|
|
59
91
|
dueDate?: string;
|
|
@@ -76,3 +108,42 @@ export function deleteInvoice(client: CoinPayClient, id: string): Promise<Record
|
|
|
76
108
|
export function publishInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
77
109
|
export function sendInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
78
110
|
export function getInvoicePaymentData(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
111
|
+
|
|
112
|
+
export interface TaxRate {
|
|
113
|
+
id: string;
|
|
114
|
+
business_id: string;
|
|
115
|
+
name: string;
|
|
116
|
+
rate: number;
|
|
117
|
+
is_default: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface InvoiceItem {
|
|
121
|
+
id: string;
|
|
122
|
+
business_id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
description: string | null;
|
|
125
|
+
unit_price: number;
|
|
126
|
+
unit: string | null;
|
|
127
|
+
tax_rate_id: string | null;
|
|
128
|
+
archived: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function listTaxRates(client: CoinPayClient, options?: { businessId?: string }): Promise<TaxRate[]>;
|
|
132
|
+
export function createTaxRate(
|
|
133
|
+
client: CoinPayClient,
|
|
134
|
+
options: { businessId?: string; name: string; rate: number; isDefault?: boolean }
|
|
135
|
+
): Promise<TaxRate>;
|
|
136
|
+
export function deleteTaxRate(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
|
137
|
+
export function listInvoiceItems(client: CoinPayClient, options?: { businessId?: string }): Promise<InvoiceItem[]>;
|
|
138
|
+
export function createInvoiceItem(
|
|
139
|
+
client: CoinPayClient,
|
|
140
|
+
options: {
|
|
141
|
+
businessId?: string;
|
|
142
|
+
name: string;
|
|
143
|
+
unitPrice: number;
|
|
144
|
+
description?: string;
|
|
145
|
+
unit?: string;
|
|
146
|
+
taxRateId?: string;
|
|
147
|
+
}
|
|
148
|
+
): Promise<InvoiceItem>;
|
|
149
|
+
export function deleteInvoiceItem(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
|
package/src/invoices.js
CHANGED
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
* @param {string} [options.businessId] - Business ID (optional for business-scoped API keys)
|
|
36
36
|
* @param {string} [options.clientId] - Client ID (optional)
|
|
37
37
|
* @param {string} options.currency - Fiat currency code (USD, EUR, etc.)
|
|
38
|
-
* @param {number} options.amount - Invoice amount
|
|
38
|
+
* @param {number} [options.amount] - Invoice amount (omit when lineItems is given)
|
|
39
|
+
* @param {Array<{description: string, quantity: number, unit_price: number, tax_rate?: number, tax_name?: string, item_id?: string}>} [options.lineItems]
|
|
40
|
+
* Itemised lines; the server computes the total from them
|
|
41
|
+
* @param {{type: 'percent'|'fixed', value: number}} [options.discount] - Invoice-level discount, applied before tax
|
|
39
42
|
* @param {string} [options.cryptoCurrency] - Preferred crypto for payment
|
|
40
43
|
* @param {string} [options.dueDate] - Due date (ISO string or YYYY-MM-DD)
|
|
41
44
|
* @param {string} [options.notes] - Notes / description
|
|
@@ -48,6 +51,8 @@ export async function createInvoice(client, {
|
|
|
48
51
|
clientId,
|
|
49
52
|
currency,
|
|
50
53
|
amount,
|
|
54
|
+
lineItems,
|
|
55
|
+
discount,
|
|
51
56
|
cryptoCurrency,
|
|
52
57
|
dueDate,
|
|
53
58
|
notes,
|
|
@@ -57,8 +62,13 @@ export async function createInvoice(client, {
|
|
|
57
62
|
const body = {
|
|
58
63
|
business_id: businessId,
|
|
59
64
|
currency,
|
|
60
|
-
amount,
|
|
61
65
|
};
|
|
66
|
+
if (lineItems) {
|
|
67
|
+
body.line_items = lineItems;
|
|
68
|
+
if (discount) body.discount = discount;
|
|
69
|
+
} else {
|
|
70
|
+
body.amount = amount;
|
|
71
|
+
}
|
|
62
72
|
if (clientId) body.client_id = clientId;
|
|
63
73
|
if (cryptoCurrency) body.crypto_currency = cryptoCurrency;
|
|
64
74
|
if (dueDate) body.due_date = dueDate;
|
|
@@ -124,7 +134,9 @@ export async function getInvoice(client, id) {
|
|
|
124
134
|
* @param {CoinPayClient} client
|
|
125
135
|
* @param {string} id - Invoice ID
|
|
126
136
|
* @param {Object} updates - Partial invoice fields to update
|
|
127
|
-
* @param {number} [updates.amount] - Updated amount
|
|
137
|
+
* @param {number} [updates.amount] - Updated amount (single-amount invoices only)
|
|
138
|
+
* @param {Array|null} [updates.lineItems] - Replace the lines (null turns itemising off)
|
|
139
|
+
* @param {Object|null} [updates.discount] - Replace the discount
|
|
128
140
|
* @param {string} [updates.currency] - Updated currency
|
|
129
141
|
* @param {string} [updates.cryptoCurrency] - Updated crypto currency
|
|
130
142
|
* @param {string} [updates.dueDate] - Updated due date
|
|
@@ -137,6 +149,8 @@ export async function getInvoice(client, id) {
|
|
|
137
149
|
export async function updateInvoice(client, id, updates) {
|
|
138
150
|
const body = {};
|
|
139
151
|
if (updates.amount !== undefined) body.amount = updates.amount;
|
|
152
|
+
if (updates.lineItems !== undefined) body.line_items = updates.lineItems;
|
|
153
|
+
if (updates.discount !== undefined) body.discount = updates.discount;
|
|
140
154
|
if (updates.currency !== undefined) body.currency = updates.currency;
|
|
141
155
|
if (updates.cryptoCurrency !== undefined) body.crypto_currency = updates.cryptoCurrency;
|
|
142
156
|
if (updates.dueDate !== undefined) body.due_date = updates.dueDate;
|
|
@@ -233,6 +247,11 @@ function normalizeInvoice(data) {
|
|
|
233
247
|
clientId: invoice.client_id,
|
|
234
248
|
currency: invoice.currency,
|
|
235
249
|
amount: invoice.amount,
|
|
250
|
+
lineItems: invoice.line_items,
|
|
251
|
+
subtotal: invoice.subtotal,
|
|
252
|
+
discount: invoice.discount,
|
|
253
|
+
discountTotal: invoice.discount_total,
|
|
254
|
+
taxTotal: invoice.tax_total,
|
|
236
255
|
cryptoCurrency: invoice.crypto_currency,
|
|
237
256
|
cryptoAmount: invoice.crypto_amount,
|
|
238
257
|
status: invoice.status,
|
|
@@ -248,3 +267,52 @@ function normalizeInvoice(data) {
|
|
|
248
267
|
updatedAt: invoice.updated_at,
|
|
249
268
|
};
|
|
250
269
|
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Catalog: saved tax rates and products/services for itemised invoices.
|
|
273
|
+
// An API key is locked to its own business, so businessId is optional there.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function businessQuery(businessId) {
|
|
277
|
+
return businessId ? '?business_id=' + encodeURIComponent(businessId) : '';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** List a business's saved tax rates. */
|
|
281
|
+
export async function listTaxRates(client, { businessId } = {}) {
|
|
282
|
+
const data = await client.request('/tax-rates' + businessQuery(businessId));
|
|
283
|
+
return data.tax_rates ?? [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Save a tax rate (percent, up to three decimals). */
|
|
287
|
+
export async function createTaxRate(client, { businessId, name, rate, isDefault }) {
|
|
288
|
+
const body = { name, rate };
|
|
289
|
+
if (businessId) body.business_id = businessId;
|
|
290
|
+
if (isDefault !== undefined) body.is_default = isDefault;
|
|
291
|
+
const data = await client.request('/tax-rates', { method: 'POST', body: JSON.stringify(body) });
|
|
292
|
+
return data.tax_rate;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function deleteTaxRate(client, id) {
|
|
296
|
+
return client.request('/tax-rates/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** List a business's saved products and services. */
|
|
300
|
+
export async function listInvoiceItems(client, { businessId } = {}) {
|
|
301
|
+
const data = await client.request('/invoice-items' + businessQuery(businessId));
|
|
302
|
+
return data.invoice_items ?? [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Save a product or service to bill again later. */
|
|
306
|
+
export async function createInvoiceItem(client, { businessId, name, unitPrice, description, unit, taxRateId }) {
|
|
307
|
+
const body = { name, unit_price: unitPrice };
|
|
308
|
+
if (businessId) body.business_id = businessId;
|
|
309
|
+
if (description) body.description = description;
|
|
310
|
+
if (unit) body.unit = unit;
|
|
311
|
+
if (taxRateId) body.tax_rate_id = taxRateId;
|
|
312
|
+
const data = await client.request('/invoice-items', { method: 'POST', body: JSON.stringify(body) });
|
|
313
|
+
return data.invoice_item;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function deleteInvoiceItem(client, id) {
|
|
317
|
+
return client.request('/invoice-items/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
318
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { createInterface } from 'node:readline';
|
|
14
14
|
|
|
15
15
|
import * as reports from './finances-reports.js';
|
|
16
|
-
import { listFinanceAccounts } from './finances.js';
|
|
16
|
+
import { getFinanceSummary, listFinanceAccounts, listFinanceTransactions } from './finances.js';
|
|
17
17
|
import * as sf from './statements-fetch.js';
|
|
18
18
|
|
|
19
19
|
export const PROTOCOL_VERSION = '2025-06-18';
|
|
@@ -28,6 +28,88 @@ export const TOOLS = [
|
|
|
28
28
|
inputSchema: { type: 'object', properties: { includeHidden: { type: 'boolean' } }, additionalProperties: false },
|
|
29
29
|
handler: async (args, { client }) => text({ accounts: await listFinanceAccounts(client, { includeHidden: args.includeHidden === true }) }),
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
name: 'finance_due_soon',
|
|
33
|
+
description:
|
|
34
|
+
'What is due across every linked account: overdue, today and the next N days. Projected from each recurring charge\'s own cadence, plus statement due days set on cards and loans. Also lists charges that stopped appearing ("missed") and paid-this-cycle statements.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
days: { type: 'integer', minimum: 0, maximum: 60, description: 'Horizon in days (default 7)' },
|
|
39
|
+
timezone: { type: 'string', description: 'IANA zone for "today"; defaults to the saved finance timezone' },
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
handler: async (args, { client }) => {
|
|
44
|
+
const { recurring, ...due } = await reports.getDueSoon(client, { days: args.days, timezone: args.timezone });
|
|
45
|
+
return text({ ...due, recurringMonthlyTotal: recurring.monthlyTotal });
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'finance_recurring',
|
|
50
|
+
description: 'Every recurring charge detected across all accounts (subscriptions, bills, loan and card payments): cadence, typical amount, last and next date, monthly equivalent.',
|
|
51
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
52
|
+
handler: async (_args, { client }) => text((await reports.getDueSoon(client, { days: 0 })).recurring),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'finance_summary',
|
|
56
|
+
description: 'Balance sheet (assets, debt, net by currency), cashflow for the window, top spending categories, and the debt-and-income position.',
|
|
57
|
+
inputSchema: { type: 'object', properties: { days: { type: 'integer', minimum: 1, maximum: 3650, description: 'Cashflow window (default 30)' } }, additionalProperties: false },
|
|
58
|
+
handler: async (args, { client }) => text(await getFinanceSummary(client, { days: args.days || 30 })),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'finance_transactions',
|
|
62
|
+
description: 'The bank and card ledger, newest first. Filter by account id, text search, category or date range (YYYY-MM-DD).',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
accountId: { type: 'string' },
|
|
67
|
+
search: { type: 'string' },
|
|
68
|
+
category: { type: 'string' },
|
|
69
|
+
startDate: { type: 'string' },
|
|
70
|
+
endDate: { type: 'string' },
|
|
71
|
+
limit: { type: 'integer', minimum: 1, maximum: 500 },
|
|
72
|
+
offset: { type: 'integer', minimum: 0 },
|
|
73
|
+
},
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
handler: async (args, { client }) => text(await listFinanceTransactions(client, { ...args, limit: args.limit || 50 })),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'finance_set_due_day',
|
|
80
|
+
description: 'Set or clear a card or loan\'s statement due day (1-31, null clears) and optionally a fixed monthly amount (null = statement balance). Puts the account on the due-soon list.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
accountId: { type: 'string' },
|
|
85
|
+
day: { type: ['integer', 'null'], minimum: 1, maximum: 31 },
|
|
86
|
+
amount: { type: ['number', 'string', 'null'] },
|
|
87
|
+
},
|
|
88
|
+
required: ['accountId', 'day'],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
handler: async (args, { client }) => text({ account: await reports.setAccountDueDay(client, args.accountId, { day: args.day, amount: args.amount }) }),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'finance_daily_summary',
|
|
95
|
+
description: 'The daily summary email (due soon, new activity, position): show it, turn it on (optionally hour 0-23 and recipients), turn it off, or send one now.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
action: { type: 'string', enum: ['show', 'on', 'off', 'send_now'] },
|
|
100
|
+
hour: { type: 'integer', minimum: 0, maximum: 23 },
|
|
101
|
+
to: { type: 'array', items: { type: 'string' }, maxItems: 5 },
|
|
102
|
+
},
|
|
103
|
+
required: ['action'],
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
},
|
|
106
|
+
handler: async (args, { client }) => {
|
|
107
|
+
if (args.action === 'on') return text({ schedule: await reports.setDailySummary(client, { hour: args.hour, recipients: args.to, active: true }) });
|
|
108
|
+
if (args.action === 'off') return text(await reports.deleteDailySummary(client));
|
|
109
|
+
if (args.action === 'send_now') return text(await reports.sendDailySummaryNow(client));
|
|
110
|
+
return text({ schedule: await reports.getDailySummary(client) });
|
|
111
|
+
},
|
|
112
|
+
},
|
|
31
113
|
{
|
|
32
114
|
name: 'statements_list',
|
|
33
115
|
description: 'List PDF statements in the CoinPay statement library, optionally for one account or period (2026-08, 2026-Q3).',
|
|
@@ -151,7 +233,7 @@ export async function handleMessage(message, ctx) {
|
|
|
151
233
|
capabilities: { tools: {} },
|
|
152
234
|
serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
|
|
153
235
|
instructions:
|
|
154
|
-
'CoinPay finances. Statement PDFs come from the banks themselves (SimpleFIN has none): statements_coverage shows what is missing, statements_fetch downloads what is new on this machine. A bank marked login_needed needs a person to run `coinpay finances statements login <bank>`. Tax notices, letters and transcripts (California FTB, IRS) are in tax_documents_list; tax data stays on CoinPay surfaces and is never sent elsewhere.',
|
|
236
|
+
'CoinPay finances. finance_due_soon answers "what is due" across every account; finance_recurring lists subscriptions and bills; finance_summary is the balance sheet. Statement PDFs come from the banks themselves (SimpleFIN has none): statements_coverage shows what is missing, statements_fetch downloads what is new on this machine. A bank marked login_needed needs a person to run `coinpay finances statements login <bank>`. Tax notices, letters and transcripts (California FTB, IRS) are in tax_documents_list; tax data stays on CoinPay surfaces and is never sent elsewhere.',
|
|
155
237
|
});
|
|
156
238
|
case 'notifications/initialized':
|
|
157
239
|
case 'notifications/cancelled':
|
|
@@ -159,7 +241,9 @@ export async function handleMessage(message, ctx) {
|
|
|
159
241
|
case 'ping':
|
|
160
242
|
return reply({});
|
|
161
243
|
case 'tools/list':
|
|
162
|
-
|
|
244
|
+
// Claude Code's client (revision 2026-07-28) rejects a whole list that
|
|
245
|
+
// lacks these three cache fields, showing "tools fetch failed".
|
|
246
|
+
return reply({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })), resultType: 'complete', ttlMs: 0, cacheScope: 'private' });
|
|
163
247
|
case 'tools/call': {
|
|
164
248
|
const tool = TOOLS.find((t) => t.name === params.name);
|
|
165
249
|
if (!tool) return fail(-32602, `Unknown tool: ${params.name}`);
|
package/src/x402.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* x402 Payment Protocol Support
|
|
3
3
|
*
|
|
4
4
|
* CoinPayPortal as the first multi-chain, multi-asset x402 facilitator.
|
|
5
|
-
* Supports native crypto (BTC, ETH, SOL, POL), USDC stablecoins,
|
|
5
|
+
* Supports native crypto (BTC, ETH, SOL, POL, XNO), USDC stablecoins,
|
|
6
6
|
* Lightning (BOLT12), and Stripe fiat — all via HTTP 402.
|
|
7
7
|
*
|
|
8
8
|
* Note: Bitcoin Cash is intentionally not listed. The x402 settle path has no
|
|
@@ -92,6 +92,18 @@ export const PAYMENT_METHODS = {
|
|
|
92
92
|
label: 'USDC on Base',
|
|
93
93
|
},
|
|
94
94
|
|
|
95
|
+
// ── Nano ───────────────────────────────────────────────────────
|
|
96
|
+
// Opt-in: only advertised when `payTo` names a Nano address (`payTo.nano`
|
|
97
|
+
// or a `nano_` string) and an XNO amount can be quoted (`rates.XNO`, or a
|
|
98
|
+
// raw `amount`). The payer sends a block; the proof carries its `blockHash`.
|
|
99
|
+
xno: {
|
|
100
|
+
network: 'nano',
|
|
101
|
+
asset: 'XNO',
|
|
102
|
+
scheme: 'exact',
|
|
103
|
+
decimals: 30,
|
|
104
|
+
label: 'Nano (XNO)',
|
|
105
|
+
},
|
|
106
|
+
|
|
95
107
|
// ── Lightning ──────────────────────────────────────────────────
|
|
96
108
|
lightning: {
|
|
97
109
|
network: 'lightning',
|
|
@@ -175,6 +187,14 @@ export function convertUsdToAssetAmount(amountUsd, methodKey, rates = {}) {
|
|
|
175
187
|
if (!rate) throw new Error(`No exchange rate for ${method.asset}`);
|
|
176
188
|
|
|
177
189
|
const assetAmount = amountUsd / rate;
|
|
190
|
+
|
|
191
|
+
if (method.network === 'nano') {
|
|
192
|
+
// 30 decimals is past float64's exact range, and String() of a number that
|
|
193
|
+
// large is exponent notation ("1e+27"), which no verifier parses. Quote to
|
|
194
|
+
// the nearest 10^-9 XNO and scale the rest in BigInt.
|
|
195
|
+
return (BigInt(Math.round(assetAmount * 1e9)) * 10n ** 21n).toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
178
198
|
const smallest = Math.round(assetAmount * Math.pow(10, method.decimals));
|
|
179
199
|
return String(smallest);
|
|
180
200
|
}
|
|
@@ -288,6 +308,8 @@ export function buildPaymentRequired(options) {
|
|
|
288
308
|
addr = payTo;
|
|
289
309
|
}
|
|
290
310
|
if (!addr) continue; // skip methods where merchant has no address
|
|
311
|
+
// A single non-Nano address must not be advertised as a Nano payee.
|
|
312
|
+
if (method.network === 'nano' && !/^(nano|xrb)_/.test(addr)) continue;
|
|
291
313
|
|
|
292
314
|
// Convert USD to asset amount
|
|
293
315
|
let assetAmount;
|
|
@@ -329,6 +351,7 @@ function _networkToMethodKey(network) {
|
|
|
329
351
|
polygon: 'pol',
|
|
330
352
|
solana: 'sol',
|
|
331
353
|
bitcoin: 'btc',
|
|
354
|
+
nano: 'xno',
|
|
332
355
|
lightning: 'lightning',
|
|
333
356
|
stripe: 'stripe',
|
|
334
357
|
};
|