@profullstack/coinpay 0.10.0 → 0.11.1
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 +19 -0
- package/README.md +8 -1
- package/bin/coinpay.js +22 -3
- package/package.json +2 -2
- package/src/finances-commands.js +193 -2
- package/src/finances-reports.d.ts +119 -1
- package/src/finances-reports.js +101 -2
- package/src/finances-tui.js +113 -71
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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.11.0] - 2026-09-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay finances books queue|confirm|confirm-all|categorize|rules|summary|export|send`:
|
|
12
|
+
every transaction auto-categorised (rules, heuristics, and Claude when the
|
|
13
|
+
server has a key) with a review queue, an "always" flag that turns a
|
|
14
|
+
correction into a rule, a tax summary by year/quarter/month in exact
|
|
15
|
+
arithmetic, and the CPA pack as CSV/PDF/HTML/JSON.
|
|
16
|
+
- `coinpay finances payloads list|download`: every raw provider response,
|
|
17
|
+
exactly as received.
|
|
18
|
+
- `coinpay finances reports send <id> --to …` and `books send --to …`: email
|
|
19
|
+
a report or the pack with attachments plus an expiring no-login link.
|
|
20
|
+
- `coinpay finances digest set|show|off|send-now`: a weekly digest on chosen
|
|
21
|
+
weekdays at a chosen local hour.
|
|
22
|
+
- `coinpay finances consent <id> on|off`: opt a connection into the
|
|
23
|
+
30-minute background sync.
|
|
24
|
+
- SDK: `finances-reports` gained the books, payloads, email and digest
|
|
25
|
+
functions with declarations.
|
|
26
|
+
|
|
8
27
|
## [0.10.0] - 2026-09-12
|
|
9
28
|
|
|
10
29
|
### Added
|
package/README.md
CHANGED
|
@@ -704,7 +704,7 @@ coinpay finances connections # institutions and their last sync
|
|
|
704
704
|
coinpay finances sync # pull fresh balances (rate-limited by the bank bridge)
|
|
705
705
|
```
|
|
706
706
|
|
|
707
|
-
The dashboard has seven screens (`1`–`7`,
|
|
707
|
+
The dashboard has seven screens (`1`–`7`, `←`/`→`): **Overview** (gross volume, crypto vs
|
|
708
708
|
cards, commission paid, processor fees, refunds, net earnings, bank position, cashflow,
|
|
709
709
|
invoices, escrow, payouts, a volume-vs-commission graph and a live feed), **Bank &
|
|
710
710
|
Cards**, **Ledger**, **Crypto**, **Cards**, **Invoices & Escrow**, **Debt & Income**.
|
|
@@ -713,6 +713,13 @@ syncs the bank feed, `w` cycles the window, `p` pauses, `?` shows help, `q` quit
|
|
|
713
713
|
refreshes every 30 seconds (`--interval`) and listens to the payments event stream, so
|
|
714
714
|
a crypto payment shows up the moment it is detected.
|
|
715
715
|
|
|
716
|
+
Click **⧉ MD** on a summary pane to copy Markdown. The header copy button exports
|
|
717
|
+
status, including bank connection health and detailed source errors. Each pane
|
|
718
|
+
carries its displayed window, snapshot timestamp, stream status and partial-data
|
|
719
|
+
warnings. Transaction rows and the live event log are excluded. Tab / Shift+Tab
|
|
720
|
+
focus controls; Enter / Space copies the focused summary. Clipboard writes use
|
|
721
|
+
OSC 52 and require terminal support, including when connected over SSH.
|
|
722
|
+
|
|
716
723
|
**Debt & Income** is the basic-accounting view, and it reads the same for a company or
|
|
717
724
|
a person: income against spending per month, total owed split into revolving and
|
|
718
725
|
instalment, months to clear each balance at its current payment rate, debt-to-income,
|
package/bin/coinpay.js
CHANGED
|
@@ -169,6 +169,11 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
169
169
|
'no-pending',
|
|
170
170
|
'liability-positive',
|
|
171
171
|
'acknowledge',
|
|
172
|
+
'always',
|
|
173
|
+
'no-model',
|
|
174
|
+
'only-uncategorized',
|
|
175
|
+
'no-attach',
|
|
176
|
+
'estimate-gaps',
|
|
172
177
|
'plain',
|
|
173
178
|
'no-stream',
|
|
174
179
|
'hidden',
|
|
@@ -473,11 +478,22 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
473
478
|
coverage What part of a period was ever fetched (--period, --account)
|
|
474
479
|
report Monthly/quarterly/custom activity report (--period 2026-08 |
|
|
475
480
|
--from/--to; --account, --scope, --timezone, --format pdf|csv|html|json,
|
|
476
|
-
--output <file>, --strict, --wait, --json)
|
|
481
|
+
--output <file>, --strict, --estimate-gaps, --wait, --json)
|
|
477
482
|
reports [get|download|delete <id>] Report history and downloads
|
|
478
483
|
statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
|
|
479
484
|
statements list|get|download|reconcile|delete
|
|
480
485
|
Statement library; reconcile takes --report --opening --closing --currency
|
|
486
|
+
books [queue] Transactions awaiting category review (--status, --scope, --search)
|
|
487
|
+
books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
|
|
488
|
+
books confirm-all Accept every pending suggestion (--yes)
|
|
489
|
+
books categorize Rules, heuristics, then the model over unreviewed rows (--wait)
|
|
490
|
+
books rules [add|delete] Merchant category rules
|
|
491
|
+
books summary Totals by tax category (--period 2026|2026-Q3|2026-08, --scope)
|
|
492
|
+
books export CPA pack (--period, --scope, --format csv|pdf|html|json, --output)
|
|
493
|
+
payloads [list|download <id>] Raw provider responses, exactly as received
|
|
494
|
+
reports send <id> Email a report (--to a@x,b@y, --format pdf,csv, --message, --no-attach)
|
|
495
|
+
books send Email the CPA pack (--to, --period, --scope, --format)
|
|
496
|
+
digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
|
|
481
497
|
|
|
482
498
|
${colors.bright}escrow${colors.reset}
|
|
483
499
|
create Create a new escrow
|
|
@@ -4271,7 +4287,10 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4271
4287
|
case 'coverage':
|
|
4272
4288
|
case 'report':
|
|
4273
4289
|
case 'reports':
|
|
4274
|
-
case 'statements':
|
|
4290
|
+
case 'statements':
|
|
4291
|
+
case 'books':
|
|
4292
|
+
case 'payloads':
|
|
4293
|
+
case 'digest': {
|
|
4275
4294
|
const { client } = financesClient();
|
|
4276
4295
|
const { runFinancesCommand } = await import('../src/finances-commands.js');
|
|
4277
4296
|
const code = await runFinancesCommand(subcommand, args, flags, {
|
|
@@ -4285,7 +4304,7 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
4285
4304
|
|
|
4286
4305
|
default:
|
|
4287
4306
|
print.error(`Unknown finances command: ${subcommand}`);
|
|
4288
|
-
console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements]');
|
|
4307
|
+
console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|payloads|digest]');
|
|
4289
4308
|
process.exit(1);
|
|
4290
4309
|
}
|
|
4291
4310
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"@inquirer/prompts": "^8.3.0",
|
|
140
140
|
"@noble/curves": "^1.6.0",
|
|
141
141
|
"@noble/hashes": "^1.5.0",
|
|
142
|
-
"@profullstack/hqtui": "^0.
|
|
142
|
+
"@profullstack/hqtui": "^0.6.3",
|
|
143
143
|
"@scure/bip32": "^1.5.0",
|
|
144
144
|
"@scure/bip39": "^1.4.0"
|
|
145
145
|
}
|
package/src/finances-commands.js
CHANGED
|
@@ -247,6 +247,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
247
247
|
includePendingAppendix: flags['no-pending'] ? false : true,
|
|
248
248
|
formats: [format, 'json'],
|
|
249
249
|
strict,
|
|
250
|
+
estimateGaps: flags['estimate-gaps'] === true,
|
|
250
251
|
idempotencyKey: typeof flags['idempotency-key'] === 'string' ? flags['idempotency-key'] : undefined,
|
|
251
252
|
});
|
|
252
253
|
let report = created.report;
|
|
@@ -295,6 +296,18 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
295
296
|
emit(data, data.reports.length ? data.reports.map(describeReport).join('\n') : 'No reports yet.');
|
|
296
297
|
return EXIT.OK;
|
|
297
298
|
}
|
|
299
|
+
if (action === 'send') {
|
|
300
|
+
const id = args[1];
|
|
301
|
+
if (!id || typeof flags.to !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports send <report-id> --to cpa@x.com,me@x.com [--format pdf,csv] [--message "…"] [--no-attach]');
|
|
302
|
+
const data = await api.sendFinanceReportEmail(client, id, {
|
|
303
|
+
to: listFlag(flags.to),
|
|
304
|
+
formats: flags.format ? listFlag(flags.format) : undefined,
|
|
305
|
+
message: typeof flags.message === 'string' ? flags.message : null,
|
|
306
|
+
attach: flags['no-attach'] ? false : true,
|
|
307
|
+
});
|
|
308
|
+
emit(data, `Sent to ${data.sent.join(', ') || 'nobody'}${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}. Attached: ${data.attached.join(', ') || 'none'}. Link (expires ${data.linkExpiresAt.slice(0, 10)}): ${data.linkUrl}`);
|
|
309
|
+
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
310
|
+
}
|
|
298
311
|
if (action === 'get' || action === 'download' || action === 'delete') {
|
|
299
312
|
const id = args[1];
|
|
300
313
|
if (!id) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances reports ${action} <report-id>`);
|
|
@@ -316,7 +329,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
316
329
|
emit({ report: data.report, output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes)`);
|
|
317
330
|
return EXIT.OK;
|
|
318
331
|
}
|
|
319
|
-
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports [list|get <id>|download <id>|delete <id>]');
|
|
332
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances reports [list|get <id>|download <id>|send <id>|delete <id>]');
|
|
320
333
|
}
|
|
321
334
|
|
|
322
335
|
case 'statements': {
|
|
@@ -404,6 +417,184 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
404
417
|
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>]');
|
|
405
418
|
}
|
|
406
419
|
|
|
420
|
+
case 'books': {
|
|
421
|
+
const action = args[0] || 'queue';
|
|
422
|
+
if (action === 'queue' || action === 'list') {
|
|
423
|
+
const scope = typeof flags.scope === 'string' ? flags.scope : undefined;
|
|
424
|
+
const data = await api.listBooksQueue(client, {
|
|
425
|
+
status: typeof flags.status === 'string' ? flags.status : 'unreviewed',
|
|
426
|
+
scope,
|
|
427
|
+
accountId: typeof flags.account === 'string' ? flags.account : undefined,
|
|
428
|
+
search: typeof flags.search === 'string' ? flags.search : undefined,
|
|
429
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
430
|
+
offset: flags.offset ? Number(flags.offset) : undefined,
|
|
431
|
+
});
|
|
432
|
+
emit(
|
|
433
|
+
data,
|
|
434
|
+
[`${data.unreviewed} row(s) awaiting review${data.modelEnabled ? '' : ' (model pass off: no ANTHROPIC_API_KEY on the server)'}`]
|
|
435
|
+
.concat(data.rows.map((r) => `${r.id} ${(r.posted || '').slice(0, 10)} ${r.amount} ${r.currency} ${(r.payee || r.description || '').slice(0, 40).padEnd(40)} ${r.category || '-'} / ${r.taxCategoryLabel} / ${r.scope} [${r.suggestion ? r.suggestion.by : r.categorySource} ${Math.round(((r.categoryConfidence ?? (r.suggestion && r.suggestion.confidence) ?? 0)) * 100)}%]`))
|
|
436
|
+
.join('\n'),
|
|
437
|
+
);
|
|
438
|
+
return EXIT.OK;
|
|
439
|
+
}
|
|
440
|
+
if (action === 'confirm') {
|
|
441
|
+
const id = args[1];
|
|
442
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books confirm <transaction-id> [--category c] [--tax t] [--scope business|personal] [--note …] [--always]');
|
|
443
|
+
const row = await api.reviewBooksTransaction(client, id, {
|
|
444
|
+
category: typeof flags.category === 'string' ? flags.category : undefined,
|
|
445
|
+
taxCategory: typeof flags.tax === 'string' ? flags.tax : undefined,
|
|
446
|
+
scope: typeof flags.scope === 'string' ? flags.scope : undefined,
|
|
447
|
+
note: typeof flags.note === 'string' ? flags.note : undefined,
|
|
448
|
+
createRule: flags.always === true,
|
|
449
|
+
});
|
|
450
|
+
emit({ transaction: row }, `Confirmed ${row.id}: ${row.category || '-'} / ${row.taxCategoryLabel} / ${row.scope}${flags.always ? ' (rule created)' : ''}`);
|
|
451
|
+
return EXIT.OK;
|
|
452
|
+
}
|
|
453
|
+
if (action === 'confirm-all') {
|
|
454
|
+
const data = await api.listBooksQueue(client, { status: 'unreviewed', limit: 500 });
|
|
455
|
+
if (!data.rows.length) { emit({ reviewed: 0 }, 'Nothing to confirm.'); return EXIT.OK; }
|
|
456
|
+
if (!flags.yes) throw new CliExit(EXIT.INVALID, `This accepts the suggestion on ${data.rows.length} row(s). Re-run with --yes.`);
|
|
457
|
+
const result = await api.bulkReviewBooks(client, data.rows.map((r) => r.id));
|
|
458
|
+
emit(result, `Confirmed ${result.reviewed} row(s) as suggested.`);
|
|
459
|
+
return EXIT.OK;
|
|
460
|
+
}
|
|
461
|
+
if (action === 'categorize') {
|
|
462
|
+
const data = await api.categorizeBooks(client, { useModel: flags['no-model'] ? false : true, onlyUncategorized: flags['only-uncategorized'] === true });
|
|
463
|
+
progress(`Queued ${describeJob(data.job)}${data.modelEnabled ? '' : ' (model pass off: no ANTHROPIC_API_KEY on the server)'}`);
|
|
464
|
+
let job = data.job;
|
|
465
|
+
if (flags.wait) job = await api.waitForFinanceJob(client, job.id, { onProgress: (j) => progress(describeJob(j)) });
|
|
466
|
+
const r = job.result || {};
|
|
467
|
+
emit({ job }, job.status === 'completed' ? `Examined ${r.examined ?? 0}: ${r.fromRules ?? 0} by rule, ${r.fromModel ?? 0} by model, ${r.autoAccepted ?? 0} accepted, ${r.queued ?? 0} for review` : describeJob(job));
|
|
468
|
+
return job.status === 'failed' ? EXIT.FAILURE : EXIT.OK;
|
|
469
|
+
}
|
|
470
|
+
if (action === 'rules') {
|
|
471
|
+
const sub = args[1];
|
|
472
|
+
if (sub === 'add') {
|
|
473
|
+
const pattern = args[2];
|
|
474
|
+
if (!pattern || typeof flags.category !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books rules add "<pattern>" --category c [--tax t] [--scope s] [--field payee|description] [--match exact|contains]');
|
|
475
|
+
const rule = await api.createBooksRule(client, { pattern, category: flags.category, taxCategory: typeof flags.tax === 'string' ? flags.tax : undefined, scope: typeof flags.scope === 'string' ? flags.scope : undefined, matchField: typeof flags.field === 'string' ? flags.field : 'payee', matchType: typeof flags.match === 'string' ? flags.match : 'exact' });
|
|
476
|
+
emit({ rule }, `Rule ${rule.id}: ${rule.match_field} ${rule.match_type} "${rule.pattern}" → ${rule.category}`);
|
|
477
|
+
return EXIT.OK;
|
|
478
|
+
}
|
|
479
|
+
if (sub === 'delete') {
|
|
480
|
+
if (!args[2]) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books rules delete <rule-id>');
|
|
481
|
+
const data = await api.deleteBooksRule(client, args[2]);
|
|
482
|
+
emit(data, 'Rule deleted.');
|
|
483
|
+
return EXIT.OK;
|
|
484
|
+
}
|
|
485
|
+
const rules = await api.listBooksRules(client);
|
|
486
|
+
emit({ rules }, rules.length ? rules.map((r) => `${r.id} ${r.match_field} ${r.match_type} "${r.pattern}" → ${r.category}${r.tax_category ? ' / ' + r.tax_category : ''}${r.scope ? ' / ' + r.scope : ''}`).join('\n') : 'No rules.');
|
|
487
|
+
return EXIT.OK;
|
|
488
|
+
}
|
|
489
|
+
if (action === 'send') {
|
|
490
|
+
if (typeof flags.to !== 'string') throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books send --to cpa@x.com,me@x.com --period 2026 [--scope business] [--format pdf,csv] [--message "…"]');
|
|
491
|
+
const period = typeof flags.period === 'string' ? flags.period : null;
|
|
492
|
+
const from = typeof flags.from === 'string' ? flags.from : undefined;
|
|
493
|
+
const toDate = typeof flags['to-date'] === 'string' ? flags['to-date'] : undefined;
|
|
494
|
+
if (!period && !(from && toDate)) throw new CliExit(EXIT.INVALID, 'Pass --period 2026 (or 2026-Q3, 2026-08) or --from/--to-date');
|
|
495
|
+
const data = await api.sendBooksEmail(client, {
|
|
496
|
+
to: listFlag(flags.to), period: period || undefined, from, toDate,
|
|
497
|
+
timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined,
|
|
498
|
+
scope: typeof flags.scope === 'string' ? flags.scope : 'business',
|
|
499
|
+
formats: flags.format ? listFlag(flags.format) : undefined,
|
|
500
|
+
message: typeof flags.message === 'string' ? flags.message : null,
|
|
501
|
+
attach: flags['no-attach'] ? false : true,
|
|
502
|
+
});
|
|
503
|
+
emit(data, `Sent to ${data.sent.join(', ') || 'nobody'}${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}. ${data.rows} rows, ${data.unreviewed} unreviewed. Link (expires ${data.linkExpiresAt.slice(0, 10)}): ${data.linkUrl}`);
|
|
504
|
+
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
505
|
+
}
|
|
506
|
+
if (action === 'summary' || action === 'export') {
|
|
507
|
+
const period = typeof flags.period === 'string' ? flags.period : typeof flags.year === 'string' || typeof flags.year === 'number' ? String(flags.year) : null;
|
|
508
|
+
const from = typeof flags.from === 'string' ? flags.from : undefined;
|
|
509
|
+
const to = typeof flags.to === 'string' ? flags.to : undefined;
|
|
510
|
+
if (!period && !(from && to)) throw new CliExit(EXIT.INVALID, 'Pass --period 2026 (or 2026-Q3, 2026-08) or --from/--to');
|
|
511
|
+
const scope = typeof flags.scope === 'string' ? flags.scope : 'business';
|
|
512
|
+
const timezone = typeof flags.timezone === 'string' ? flags.timezone : undefined;
|
|
513
|
+
if (action === 'summary') {
|
|
514
|
+
const data = await api.getBooksSummary(client, { period: period || undefined, from, to, timezone, scope });
|
|
515
|
+
emit(
|
|
516
|
+
data,
|
|
517
|
+
[`${data.period.label} · ${scope} · ${data.rows} rows (${data.unreviewed} unreviewed, ${data.uncategorized} uncategorised)`]
|
|
518
|
+
.concat(data.totals.map((t) => `${t.currency}: income ${t.income} · expenses ${t.expenses} · net ${t.net} · excluded ${t.excluded}`))
|
|
519
|
+
.concat(data.lines.map((l) => ` ${l.label.padEnd(32)} ${l.currency} ${l.total.padStart(14)} (${l.rows})${l.excluded ? ' excluded' : ''}`))
|
|
520
|
+
.concat([data.notice])
|
|
521
|
+
.join('\n'),
|
|
522
|
+
);
|
|
523
|
+
return EXIT.OK;
|
|
524
|
+
}
|
|
525
|
+
const format = typeof flags.format === 'string' ? flags.format.toLowerCase() : 'csv';
|
|
526
|
+
const file = await api.exportBooks(client, { period: period || undefined, from, to, timezone, scope, format });
|
|
527
|
+
const output = typeof flags.output === 'string' ? flags.output : file.filename || `books-${period || 'range'}.${format}`;
|
|
528
|
+
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
529
|
+
emit({ output: path, bytes: file.bytes.length, unreviewed: Number(file.headers['x-unreviewed-rows'] || 0) }, `Wrote ${path} (${file.bytes.length} bytes; ${file.headers['x-unreviewed-rows'] || 0} unreviewed rows)`);
|
|
530
|
+
return EXIT.OK;
|
|
531
|
+
}
|
|
532
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances books [queue|confirm <id>|confirm-all --yes|categorize|rules [add|delete]|summary|export|send]');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
case 'digest': {
|
|
536
|
+
const action = args[0] || 'show';
|
|
537
|
+
const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
538
|
+
const describe = (s) => s
|
|
539
|
+
? `${s.active ? 'on' : 'off'} · ${s.weekdays.map((d) => DAY_NAMES[d]).join(',')} at ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · ${s.scope} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}${s.lastSentAt ? ` · last ${s.lastSentAt}` : ''}`
|
|
540
|
+
: 'No weekly digest configured. Set one with: coinpay finances digest set --days mon,fri --hour 8 --to you@example.com';
|
|
541
|
+
if (action === 'show') {
|
|
542
|
+
const s = await api.getWeeklyDigest(client);
|
|
543
|
+
emit({ schedule: s }, describe(s));
|
|
544
|
+
return EXIT.OK;
|
|
545
|
+
}
|
|
546
|
+
if (action === 'set' || action === 'on') {
|
|
547
|
+
const days = flags.days ? listFlag(flags.days).map((d) => { const i = DAY_NAMES.indexOf(d.toLowerCase().slice(0, 3)); return i === -1 ? Number(d) : i; }) : undefined;
|
|
548
|
+
if (days && days.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) throw new CliExit(EXIT.INVALID, '--days takes names like mon,fri or numbers 0 (Sunday) to 6');
|
|
549
|
+
const s = await api.setWeeklyDigest(client, {
|
|
550
|
+
weekdays: days,
|
|
551
|
+
hour: flags.hour !== undefined ? Number(flags.hour) : undefined,
|
|
552
|
+
timezone: typeof flags.timezone === 'string' ? flags.timezone : undefined,
|
|
553
|
+
recipients: flags.to ? listFlag(flags.to) : undefined,
|
|
554
|
+
scope: typeof flags.scope === 'string' ? flags.scope : undefined,
|
|
555
|
+
formats: flags.format ? listFlag(flags.format) : undefined,
|
|
556
|
+
active: true,
|
|
557
|
+
});
|
|
558
|
+
emit({ schedule: s }, describe(s));
|
|
559
|
+
return EXIT.OK;
|
|
560
|
+
}
|
|
561
|
+
if (action === 'off' || action === 'pause') {
|
|
562
|
+
const s = await api.setWeeklyDigest(client, { active: false });
|
|
563
|
+
emit({ schedule: s }, describe(s));
|
|
564
|
+
return EXIT.OK;
|
|
565
|
+
}
|
|
566
|
+
if (action === 'delete') {
|
|
567
|
+
const data = await api.deleteWeeklyDigest(client);
|
|
568
|
+
emit(data, 'Weekly digest removed.');
|
|
569
|
+
return EXIT.OK;
|
|
570
|
+
}
|
|
571
|
+
if (action === 'send-now') {
|
|
572
|
+
const data = await api.sendWeeklyDigestNow(client);
|
|
573
|
+
emit(data, `Sent to ${data.sent.join(', ') || 'nobody'}${data.failed.length ? `; failed: ${data.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
|
|
574
|
+
return data.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
|
|
575
|
+
}
|
|
576
|
+
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]');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
case 'payloads': {
|
|
580
|
+
const action = args[0] || 'list';
|
|
581
|
+
if (action === 'list') {
|
|
582
|
+
const data = await api.listFinancePayloads(client, { connectionId: typeof flags.connection === 'string' ? flags.connection : undefined, limit: flags.limit ? Number(flags.limit) : undefined });
|
|
583
|
+
emit(data, data.payloads.length ? data.payloads.map((p) => `${p.id} ${p.fetchedAt} ${p.provider} ${p.requestClass} ${p.accounts} accounts / ${p.transactions} tx / ${p.errors} errors ${p.bytes} bytes`).join('\n') : 'No archived payloads yet.');
|
|
584
|
+
return EXIT.OK;
|
|
585
|
+
}
|
|
586
|
+
if (action === 'download') {
|
|
587
|
+
const id = args[1];
|
|
588
|
+
if (!id) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances payloads download <payload-id> --output <file.json>');
|
|
589
|
+
const file = await api.downloadFinancePayload(client, id);
|
|
590
|
+
const output = typeof flags.output === 'string' ? flags.output : file.filename || `${id}.json`;
|
|
591
|
+
const path = writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true });
|
|
592
|
+
emit({ output: path, bytes: file.bytes.length, sha256: file.sha256 }, `Wrote ${path} (${file.bytes.length} bytes, as received from the provider)`);
|
|
593
|
+
return EXIT.OK;
|
|
594
|
+
}
|
|
595
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances payloads [list|download <id>]');
|
|
596
|
+
}
|
|
597
|
+
|
|
407
598
|
default:
|
|
408
599
|
return null;
|
|
409
600
|
}
|
|
@@ -420,4 +611,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
420
611
|
}
|
|
421
612
|
}
|
|
422
613
|
|
|
423
|
-
export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements'];
|
|
614
|
+
export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'payloads', 'digest'];
|
|
@@ -5,7 +5,7 @@ export type ReconciliationStatus = 'not_attempted' | 'unavailable' | 'mismatch'
|
|
|
5
5
|
export type ReportFormat = 'pdf' | 'html' | 'csv' | 'json';
|
|
6
6
|
export type ReportStatus = 'queued' | 'generating' | 'ready' | 'failed' | 'superseded' | 'deleted';
|
|
7
7
|
export type JobStatus = 'queued' | 'running' | 'waiting_for_budget' | 'partial' | 'failed' | 'completed' | 'cancelled';
|
|
8
|
-
export type JobKind = 'backfill' | 'refresh' | 'scheduled_sync' | 'report';
|
|
8
|
+
export type JobKind = 'backfill' | 'refresh' | 'scheduled_sync' | 'report' | 'categorize' | 'email_report';
|
|
9
9
|
|
|
10
10
|
/** Thrown by every call here when the server answers with its structured error body. */
|
|
11
11
|
export interface FinanceApiError extends Error {
|
|
@@ -196,6 +196,8 @@ export function createFinanceReport(
|
|
|
196
196
|
includePendingAppendix?: boolean;
|
|
197
197
|
formats?: ReportFormat[];
|
|
198
198
|
strict?: boolean;
|
|
199
|
+
/** Fill the days before the first observed posting with a labelled extrapolation. */
|
|
200
|
+
estimateGaps?: boolean;
|
|
199
201
|
idempotencyKey?: string;
|
|
200
202
|
},
|
|
201
203
|
): Promise<{ report: FinanceReport; job: FinanceJob }>;
|
|
@@ -241,3 +243,119 @@ export function reconcileFinanceStatement(
|
|
|
241
243
|
acknowledge?: boolean;
|
|
242
244
|
},
|
|
243
245
|
): Promise<FinanceReconciliation>;
|
|
246
|
+
|
|
247
|
+
export interface BooksRow {
|
|
248
|
+
id: string;
|
|
249
|
+
accountId: string;
|
|
250
|
+
accountName: string;
|
|
251
|
+
orgName: string | null;
|
|
252
|
+
currency: string;
|
|
253
|
+
posted: string | null;
|
|
254
|
+
amount: string;
|
|
255
|
+
payee: string | null;
|
|
256
|
+
description: string | null;
|
|
257
|
+
memo: string | null;
|
|
258
|
+
category: string | null;
|
|
259
|
+
categorySource: 'auto' | 'rule' | 'model' | 'user';
|
|
260
|
+
categoryConfidence: number | null;
|
|
261
|
+
taxCategory: string | null;
|
|
262
|
+
taxCategoryLabel: string;
|
|
263
|
+
scope: 'business' | 'personal' | string;
|
|
264
|
+
scopeOverride: string | null;
|
|
265
|
+
accountScope: string;
|
|
266
|
+
reviewedAt: string | null;
|
|
267
|
+
note: string | null;
|
|
268
|
+
suggestion: { category: string | null; taxCategory: string | null; confidence: number | null; by: string | null } | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface BooksRule {
|
|
272
|
+
id: string;
|
|
273
|
+
match_field: 'payee' | 'description';
|
|
274
|
+
match_type: 'exact' | 'contains';
|
|
275
|
+
pattern: string;
|
|
276
|
+
category: string;
|
|
277
|
+
tax_category: string | null;
|
|
278
|
+
scope: 'business' | 'personal' | null;
|
|
279
|
+
hits: number;
|
|
280
|
+
active: boolean;
|
|
281
|
+
created_at: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface BooksSummaryLine {
|
|
285
|
+
taxCategory: string;
|
|
286
|
+
label: string;
|
|
287
|
+
currency: string;
|
|
288
|
+
total: string;
|
|
289
|
+
rows: number;
|
|
290
|
+
excluded: boolean;
|
|
291
|
+
income: boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function listBooksQueue(
|
|
295
|
+
client: CoinPayClient,
|
|
296
|
+
options?: { status?: 'unreviewed' | 'reviewed' | 'all'; scope?: 'business' | 'personal' | 'all'; accountId?: string; search?: string; start?: string; end?: string; limit?: number; offset?: number },
|
|
297
|
+
): Promise<{ rows: BooksRow[]; total: number; unreviewed: number; categories: string[]; taxCategories: Array<{ id: string; label: string }>; modelEnabled: boolean }>;
|
|
298
|
+
export function reviewBooksTransaction(
|
|
299
|
+
client: CoinPayClient,
|
|
300
|
+
transactionId: string,
|
|
301
|
+
options?: { category?: string | null; taxCategory?: string | null; scope?: 'business' | 'personal' | null; note?: string | null; createRule?: boolean },
|
|
302
|
+
): Promise<BooksRow>;
|
|
303
|
+
export function bulkReviewBooks(
|
|
304
|
+
client: CoinPayClient,
|
|
305
|
+
ids: string[],
|
|
306
|
+
options?: { category?: string | null; taxCategory?: string | null; scope?: 'business' | 'personal' | null; createRule?: boolean },
|
|
307
|
+
): Promise<{ reviewed: number }>;
|
|
308
|
+
export function categorizeBooks(client: CoinPayClient, options?: { useModel?: boolean; onlyUncategorized?: boolean }): Promise<{ job: FinanceJob; modelEnabled: boolean }>;
|
|
309
|
+
export function listBooksRules(client: CoinPayClient): Promise<BooksRule[]>;
|
|
310
|
+
export function createBooksRule(
|
|
311
|
+
client: CoinPayClient,
|
|
312
|
+
options: { matchField?: 'payee' | 'description'; matchType?: 'exact' | 'contains'; pattern: string; category: string; taxCategory?: string | null; scope?: 'business' | 'personal' | null },
|
|
313
|
+
): Promise<BooksRule>;
|
|
314
|
+
export function deleteBooksRule(client: CoinPayClient, ruleId: string): Promise<{ success: boolean }>;
|
|
315
|
+
export function getBooksSummary(
|
|
316
|
+
client: CoinPayClient,
|
|
317
|
+
options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; rows?: boolean },
|
|
318
|
+
): Promise<{ period: Record<string, unknown>; scope: string; lines: BooksSummaryLine[]; totals: Array<{ currency: string; income: string; expenses: string; net: string; excluded: string }>; rows: number; unreviewed: number; uncategorized: number; notice: string; transactions?: BooksRow[] }>;
|
|
319
|
+
export function exportBooks(client: CoinPayClient, options?: PeriodSelection & { scope?: 'business' | 'personal' | 'all'; format?: 'csv' | 'pdf' | 'html' | 'json' }): Promise<BinaryResponse>;
|
|
320
|
+
export function listFinancePayloads(client: CoinPayClient, options?: { connectionId?: string; limit?: number; offset?: number }): Promise<{ payloads: Array<Record<string, unknown>>; total: number }>;
|
|
321
|
+
export function downloadFinancePayload(client: CoinPayClient, payloadId: string): Promise<BinaryResponse>;
|
|
322
|
+
|
|
323
|
+
export interface SendOutcome {
|
|
324
|
+
sent: string[];
|
|
325
|
+
failed: Array<{ to: string; error: string }>;
|
|
326
|
+
attached: string[];
|
|
327
|
+
linkUrl: string;
|
|
328
|
+
linkExpiresAt: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface WeeklyDigest {
|
|
332
|
+
id: string;
|
|
333
|
+
kind: 'weekly_digest';
|
|
334
|
+
weekdays: number[];
|
|
335
|
+
hour: number;
|
|
336
|
+
timezone: string;
|
|
337
|
+
recipients: string[];
|
|
338
|
+
scope: 'business' | 'personal' | 'all';
|
|
339
|
+
formats: string[];
|
|
340
|
+
active: boolean;
|
|
341
|
+
lastSentAt: string | null;
|
|
342
|
+
nextRunAt: string | null;
|
|
343
|
+
createdAt: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function sendFinanceReportEmail(
|
|
347
|
+
client: CoinPayClient,
|
|
348
|
+
reportId: string,
|
|
349
|
+
options: { to: string | string[]; formats?: ReportFormat[]; message?: string | null; attach?: boolean; expiresInDays?: number },
|
|
350
|
+
): Promise<SendOutcome>;
|
|
351
|
+
export function sendBooksEmail(
|
|
352
|
+
client: CoinPayClient,
|
|
353
|
+
options: { to: string | string[]; period?: string; from?: string; toDate?: string; timezone?: string; scope?: 'business' | 'personal' | 'all'; formats?: string[]; message?: string | null; attach?: boolean; expiresInDays?: number },
|
|
354
|
+
): Promise<SendOutcome & { unreviewed: number; rows: number }>;
|
|
355
|
+
export function getWeeklyDigest(client: CoinPayClient): Promise<WeeklyDigest | null>;
|
|
356
|
+
export function setWeeklyDigest(
|
|
357
|
+
client: CoinPayClient,
|
|
358
|
+
options?: { weekdays?: number[]; hour?: number; timezone?: string; recipients?: string[]; scope?: 'business' | 'personal' | 'all'; formats?: string[]; active?: boolean },
|
|
359
|
+
): Promise<WeeklyDigest>;
|
|
360
|
+
export function deleteWeeklyDigest(client: CoinPayClient): Promise<{ success: boolean }>;
|
|
361
|
+
export function sendWeeklyDigestNow(client: CoinPayClient): Promise<SendOutcome>;
|
package/src/finances-reports.js
CHANGED
|
@@ -156,12 +156,12 @@ export async function getFinanceCoverage(client, { period, from, to, timezone, a
|
|
|
156
156
|
* use `waitForFinanceReport` to block until it is ready.
|
|
157
157
|
*/
|
|
158
158
|
export async function createFinanceReport(client, {
|
|
159
|
-
period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, idempotencyKey,
|
|
159
|
+
period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, estimateGaps, idempotencyKey,
|
|
160
160
|
} = {}) {
|
|
161
161
|
return call(client, '/finances/reports', {
|
|
162
162
|
method: 'POST',
|
|
163
163
|
headers: headersWith(idempotencyKey),
|
|
164
|
-
body: JSON.stringify({ period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict }),
|
|
164
|
+
body: JSON.stringify({ period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, estimateGaps }),
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
167
|
|
|
@@ -255,3 +255,102 @@ export async function reconcileFinanceStatement(client, statementId, {
|
|
|
255
255
|
});
|
|
256
256
|
return data.reconciliation;
|
|
257
257
|
}
|
|
258
|
+
|
|
259
|
+
// ── Books ──
|
|
260
|
+
|
|
261
|
+
/** Rows awaiting review (default) plus the category vocabularies. */
|
|
262
|
+
export async function listBooksQueue(client, { status, scope, accountId, search, start, end, limit, offset } = {}) {
|
|
263
|
+
return call(client, `/finances/books/queue${query({ status, scope, account: accountId, search, start, end, limit, offset })}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Confirm one row. Omitted fields keep the row's current values. */
|
|
267
|
+
export async function reviewBooksTransaction(client, transactionId, { category, taxCategory, scope, note, createRule } = {}) {
|
|
268
|
+
const data = await call(client, `/finances/books/transactions/${encodeURIComponent(transactionId)}`, {
|
|
269
|
+
method: 'PATCH',
|
|
270
|
+
body: JSON.stringify({ category, taxCategory, scope, note, createRule }),
|
|
271
|
+
});
|
|
272
|
+
return data.transaction;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Confirm many rows; with no category given each row's suggestion is accepted. */
|
|
276
|
+
export async function bulkReviewBooks(client, ids, { category, taxCategory, scope, createRule } = {}) {
|
|
277
|
+
return call(client, '/finances/books/bulk', { method: 'POST', body: JSON.stringify({ ids, category, taxCategory, scope, createRule }) });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Queue an auto-categorisation run over unreviewed rows. Answers with the job. */
|
|
281
|
+
export async function categorizeBooks(client, { useModel = true, onlyUncategorized = false } = {}) {
|
|
282
|
+
return call(client, '/finances/books/categorize', { method: 'POST', body: JSON.stringify({ useModel, onlyUncategorized }) });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function listBooksRules(client) {
|
|
286
|
+
const data = await call(client, '/finances/books/rules');
|
|
287
|
+
return data.rules || [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function createBooksRule(client, { matchField = 'payee', matchType = 'exact', pattern, category, taxCategory, scope } = {}) {
|
|
291
|
+
const data = await call(client, '/finances/books/rules', { method: 'POST', body: JSON.stringify({ matchField, matchType, pattern, category, taxCategory, scope }) });
|
|
292
|
+
return data.rule;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function deleteBooksRule(client, ruleId) {
|
|
296
|
+
return call(client, `/finances/books/rules/${encodeURIComponent(ruleId)}`, { method: 'DELETE' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Totals by tax category for a year (`2026`), quarter or month. */
|
|
300
|
+
export async function getBooksSummary(client, { period, from, to, timezone, scope, rows } = {}) {
|
|
301
|
+
return call(client, `/finances/books/summary${query({ period, from, to, timezone, scope, rows: rows ? 1 : undefined })}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The CPA pack as bytes. */
|
|
305
|
+
export async function exportBooks(client, { period, from, to, timezone, scope, format = 'csv' } = {}) {
|
|
306
|
+
return client.requestBinary(`/finances/books/export${query({ period, from, to, timezone, scope, format })}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Raw provider payloads ──
|
|
310
|
+
|
|
311
|
+
export async function listFinancePayloads(client, { connectionId, limit, offset } = {}) {
|
|
312
|
+
return call(client, `/finances/payloads${query({ connection: connectionId, limit, offset })}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function downloadFinancePayload(client, payloadId) {
|
|
316
|
+
return client.requestBinary(`/finances/payloads/${encodeURIComponent(payloadId)}/download`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Email ──
|
|
320
|
+
|
|
321
|
+
/** Email a ready report: attachments plus an expiring no-login link. */
|
|
322
|
+
export async function sendFinanceReportEmail(client, reportId, { to, formats, message, attach, expiresInDays } = {}) {
|
|
323
|
+
return call(client, `/finances/reports/${encodeURIComponent(reportId)}/send`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
body: JSON.stringify({ to, formats, message, attach, expiresInDays }),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Email the CPA pack for a period. `toDate` is the exclusive end for a custom range. */
|
|
330
|
+
export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays } = {}) {
|
|
331
|
+
return call(client, '/finances/books/send', {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays }),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function getWeeklyDigest(client) {
|
|
338
|
+
const data = await call(client, '/finances/email-schedules');
|
|
339
|
+
return data.schedule;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function setWeeklyDigest(client, { weekdays, hour, timezone, recipients, scope, formats, active } = {}) {
|
|
343
|
+
const data = await call(client, '/finances/email-schedules', {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
body: JSON.stringify({ weekdays, hour, timezone, recipients, scope, formats, active }),
|
|
346
|
+
});
|
|
347
|
+
return data.schedule;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function deleteWeeklyDigest(client) {
|
|
351
|
+
return call(client, '/finances/email-schedules', { method: 'DELETE' });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function sendWeeklyDigestNow(client) {
|
|
355
|
+
return call(client, '/finances/email-schedules/send-now', { method: 'POST', body: '{}' });
|
|
356
|
+
}
|
package/src/finances-tui.js
CHANGED
|
@@ -95,7 +95,7 @@ function num(v) {
|
|
|
95
95
|
|
|
96
96
|
// ── State ──
|
|
97
97
|
|
|
98
|
-
function createState(days) {
|
|
98
|
+
export function createState(days) {
|
|
99
99
|
return {
|
|
100
100
|
tab: 0,
|
|
101
101
|
days,
|
|
@@ -732,6 +732,111 @@ function positionScreen(ui, state, theme) {
|
|
|
732
732
|
|
|
733
733
|
const SCREENS = [overviewScreen, bankScreen, ledgerScreen, cryptoScreen, cardsScreen, invoicesScreen, positionScreen];
|
|
734
734
|
|
|
735
|
+
/** Context exported with each pane; the displayed snapshot can lag the requested window. */
|
|
736
|
+
export function financeMarkdownContext(state) {
|
|
737
|
+
const s = state.snapshot;
|
|
738
|
+
return [
|
|
739
|
+
`CoinPay Finances · ${TABS[state.tab]}`,
|
|
740
|
+
`Payment business filter: ${state.businessId || 'all businesses'}; bank data is merchant-wide`,
|
|
741
|
+
`Displayed window: ${s?.windowDays ?? state.days} days`,
|
|
742
|
+
...(s && s.windowDays !== state.days ? [`Requested window: ${state.days} days (refresh pending)`] : []),
|
|
743
|
+
`Snapshot: ${s?.generatedAt || 'not loaded'}`,
|
|
744
|
+
`Updates: ${state.loading ? 'refreshing' : state.paused ? 'paused' : 'live'}`,
|
|
745
|
+
`Payment stream: ${state.liveStatus}`,
|
|
746
|
+
...(state.error ? [`Refresh failed: ${state.error}`] : []),
|
|
747
|
+
...Object.entries(s?.errors || {}).map(([source, error]) => `Source unavailable — ${source}: ${error}`),
|
|
748
|
+
...(s?.crypto.partial ? ['Crypto details cover a partial page.'] : []),
|
|
749
|
+
...(s?.card.partial ? ['Card details, processor fees and refunds cover a partial page.'] : []),
|
|
750
|
+
...(s?.position ? [`Debt & Income: ${s.position.observedDays ?? s.position.lookbackDays} days observed; independent of the dashboard window.`] : []),
|
|
751
|
+
].join('\n');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Status can be copied without including any live payment or transaction rows. */
|
|
755
|
+
export function financeStatusMarkdown(state, markdownText) {
|
|
756
|
+
return [
|
|
757
|
+
'## CoinPay status',
|
|
758
|
+
markdownText(financeMarkdownContext(state)),
|
|
759
|
+
`Bank sync: ${state.syncing ? 'running' : 'idle'}`,
|
|
760
|
+
...(state.notice ? [markdownText(state.notice)] : []),
|
|
761
|
+
...(state.snapshot?.bank.connections || []).map((c) => markdownText(
|
|
762
|
+
`${c.label || c.provider || 'Bank connection'}: ${c.is_active ? 'active' : 'inactive'}; last sync ${c.last_synced_at || 'never'} (${c.last_sync_status || 'unknown'})${c.last_sync_error ? `; ${c.last_sync_error}` : ''}`,
|
|
763
|
+
)),
|
|
764
|
+
].join('\n\n') + '\n';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Draw the same summary controls in production and headless interaction tests. */
|
|
768
|
+
export function renderFinancesTui(ui, state, t, { height, interval = 30, markdownText }) {
|
|
769
|
+
ui.row({ size: 1 }, (header) => {
|
|
770
|
+
header.text(' CoinPay ', { fg: t.title, bold: true, size: 10 });
|
|
771
|
+
header.tabs({
|
|
772
|
+
tabs: TABS.map((name, i) => `${i + 1} ${name}`),
|
|
773
|
+
active: state.tab,
|
|
774
|
+
onSelect: (index) => { state.tab = index; },
|
|
775
|
+
});
|
|
776
|
+
const right = [
|
|
777
|
+
state.paused ? 'paused' : state.loading ? 'loading…' : `${state.days}d`,
|
|
778
|
+
state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : 'starting',
|
|
779
|
+
clock(),
|
|
780
|
+
].join(' ');
|
|
781
|
+
header.text(`${right} `, { fg: state.paused ? t.warning : state.error ? t.danger : t.success, align: 'right' });
|
|
782
|
+
header.copyButton({ markdown: () => financeStatusMarkdown(state, markdownText), width: 6 });
|
|
783
|
+
});
|
|
784
|
+
ui.spacer(1);
|
|
785
|
+
|
|
786
|
+
ui.column({ size: height - 4 }, (body) => {
|
|
787
|
+
if (!state.snapshot) {
|
|
788
|
+
body.panel({ title: 'Finances' }, (p) => {
|
|
789
|
+
p.text(state.error ? `Could not load: ${state.error}` : 'Loading your numbers…', { fg: state.error ? t.danger : t.muted });
|
|
790
|
+
if (state.error) p.text('Press r to retry, q to quit.', { fg: t.muted });
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
SCREENS[state.tab](body, state, t);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
ui.spacer(1);
|
|
798
|
+
const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0;
|
|
799
|
+
ui.statusBar({
|
|
800
|
+
items: [
|
|
801
|
+
{ key: '1-7', label: 'Screen' },
|
|
802
|
+
{ key: 'r', label: 'Refresh' },
|
|
803
|
+
{ key: 's', label: state.syncing ? 'Syncing…' : 'Sync bank', active: state.syncing },
|
|
804
|
+
{ key: 'w', label: `Window ${state.days}d` },
|
|
805
|
+
{ key: 'p', label: state.paused ? 'Resume' : 'Pause', active: state.paused },
|
|
806
|
+
{ key: '?', label: 'Help' },
|
|
807
|
+
{ key: 'q', label: 'Quit' },
|
|
808
|
+
],
|
|
809
|
+
right: [
|
|
810
|
+
...(errorCount ? [{ label: `${errorCount} source${errorCount > 1 ? 's' : ''} unavailable`, color: t.warning }] : []),
|
|
811
|
+
{ label: state.liveStatus, color: state.liveStatus === 'stream connected' ? t.success : t.muted },
|
|
812
|
+
],
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
if (state.showHelp) {
|
|
816
|
+
ui.modal({
|
|
817
|
+
title: 'CoinPay Finances — Help',
|
|
818
|
+
width: 66,
|
|
819
|
+
height: 22,
|
|
820
|
+
message:
|
|
821
|
+
'1-7, ←/→ switch screens.\n' +
|
|
822
|
+
'r refreshes now; refresh also runs every ' + Math.max(5, interval) + 's.\n' +
|
|
823
|
+
's pulls fresh bank balances and transactions (rate-limited\n' +
|
|
824
|
+
' by the bank bridge, so it is never automatic).\n' +
|
|
825
|
+
'w cycles the window: 7 → 30 → 90 → 365 days.\n' +
|
|
826
|
+
'p pauses the timer. ↑/↓ j/k, PgUp/PgDn, Home/End scroll.\n' +
|
|
827
|
+
'Mouse: click tabs, scroll tables.\n\n' +
|
|
828
|
+
'⧉ MD copies a summary. Tab focuses, Enter copies.\n\n' +
|
|
829
|
+
'Commission paid = platform fees on crypto + card payments.\n' +
|
|
830
|
+
'Net earnings = gross − commission − processor fees − refunds.\n' +
|
|
831
|
+
'Debt & Income covers 180 days regardless of w, and excludes\n' +
|
|
832
|
+
' transfers and card payments from both sides.\n\n' +
|
|
833
|
+
'Esc or Close dismisses help.',
|
|
834
|
+
buttons: [{ label: 'Close', onPress: () => { state.showHelp = false; } }],
|
|
835
|
+
onDismiss: () => { state.showHelp = false; },
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
735
840
|
// ── App ──
|
|
736
841
|
|
|
737
842
|
async function loadHqtui() {
|
|
@@ -755,8 +860,13 @@ async function loadHqtui() {
|
|
|
755
860
|
*/
|
|
756
861
|
export async function runFinancesTui({ client, baseUrl, token, days = 30, interval = 30, businessId, limit = 100, theme } = {}) {
|
|
757
862
|
const hqtui = await loadHqtui();
|
|
758
|
-
const app = await hqtui.createApp({ fps: 30, theme: theme || 'dark', quitKeys: ['ctrl+c', 'q'] });
|
|
759
863
|
const state = createState(days);
|
|
864
|
+
state.businessId = businessId;
|
|
865
|
+
const app = await hqtui.createApp({
|
|
866
|
+
fps: 30, theme: theme || 'dark', quitKeys: ['ctrl+c', 'q'],
|
|
867
|
+
copyMarkdown: true,
|
|
868
|
+
markdownContext: () => financeMarkdownContext(state),
|
|
869
|
+
});
|
|
760
870
|
|
|
761
871
|
let refreshing = false;
|
|
762
872
|
let refreshTimer = null;
|
|
@@ -867,7 +977,6 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
|
|
|
867
977
|
return;
|
|
868
978
|
}
|
|
869
979
|
switch (name) {
|
|
870
|
-
case 'tab':
|
|
871
980
|
case 'right':
|
|
872
981
|
case 'l':
|
|
873
982
|
state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length;
|
|
@@ -925,74 +1034,7 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
|
|
|
925
1034
|
app.invalidate();
|
|
926
1035
|
});
|
|
927
1036
|
|
|
928
|
-
app.render(({ ui, theme: t, height }) => {
|
|
929
|
-
ui.row({ size: 1 }, (header) => {
|
|
930
|
-
header.text(' CoinPay ', { fg: t.title, bold: true, size: 10 });
|
|
931
|
-
header.tabs({
|
|
932
|
-
tabs: TABS.map((name, i) => `${i + 1} ${name}`),
|
|
933
|
-
active: state.tab,
|
|
934
|
-
onSelect: (index) => { state.tab = index; },
|
|
935
|
-
});
|
|
936
|
-
const right = [
|
|
937
|
-
state.paused ? 'paused' : state.loading ? 'loading…' : `${state.days}d`,
|
|
938
|
-
state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : 'starting',
|
|
939
|
-
clock(),
|
|
940
|
-
].join(' ');
|
|
941
|
-
header.text(`${right} `, { fg: state.paused ? t.warning : state.error ? t.danger : t.success, align: 'right' });
|
|
942
|
-
});
|
|
943
|
-
ui.spacer(1);
|
|
944
|
-
|
|
945
|
-
ui.column({ size: height - 4 }, (body) => {
|
|
946
|
-
if (!state.snapshot) {
|
|
947
|
-
body.panel({ title: 'Finances' }, (p) => {
|
|
948
|
-
p.text(state.error ? `Could not load: ${state.error}` : 'Loading your numbers…', { fg: state.error ? t.danger : t.muted });
|
|
949
|
-
if (state.error) p.text('Press r to retry, q to quit.', { fg: t.muted });
|
|
950
|
-
});
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
SCREENS[state.tab](body, state, t);
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
ui.spacer(1);
|
|
957
|
-
const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0;
|
|
958
|
-
ui.statusBar({
|
|
959
|
-
items: [
|
|
960
|
-
{ key: '1-7', label: 'Screen' },
|
|
961
|
-
{ key: 'r', label: 'Refresh' },
|
|
962
|
-
{ key: 's', label: state.syncing ? 'Syncing…' : 'Sync bank', active: state.syncing },
|
|
963
|
-
{ key: 'w', label: `Window ${state.days}d` },
|
|
964
|
-
{ key: 'p', label: state.paused ? 'Resume' : 'Pause', active: state.paused },
|
|
965
|
-
{ key: '?', label: 'Help' },
|
|
966
|
-
{ key: 'q', label: 'Quit' },
|
|
967
|
-
],
|
|
968
|
-
right: [
|
|
969
|
-
...(errorCount ? [{ label: `${errorCount} source${errorCount > 1 ? 's' : ''} unavailable`, color: t.warning }] : []),
|
|
970
|
-
{ label: state.liveStatus, color: state.liveStatus === 'stream connected' ? t.success : t.muted },
|
|
971
|
-
],
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
if (state.showHelp) {
|
|
975
|
-
ui.modal({
|
|
976
|
-
title: 'CoinPay Finances — Help',
|
|
977
|
-
width: 66,
|
|
978
|
-
height: 20,
|
|
979
|
-
message:
|
|
980
|
-
'1-7, Tab, ←/→ switch screens.\n' +
|
|
981
|
-
'r refreshes now; refresh also runs every ' + Math.max(5, interval) + 's.\n' +
|
|
982
|
-
's pulls fresh bank balances and transactions (rate-limited\n' +
|
|
983
|
-
' by the bank bridge, so it is never automatic).\n' +
|
|
984
|
-
'w cycles the window: 7 → 30 → 90 → 365 days.\n' +
|
|
985
|
-
'p pauses the timer. ↑/↓ j/k, PgUp/PgDn, Home/End scroll.\n' +
|
|
986
|
-
'Mouse: click tabs, scroll tables.\n\n' +
|
|
987
|
-
'Commission paid = platform fees on crypto + card payments.\n' +
|
|
988
|
-
'Net earnings = gross − commission − processor fees − refunds.\n' +
|
|
989
|
-
'Debt & Income covers 180 days regardless of w, and excludes\n' +
|
|
990
|
-
' transfers and card payments from both sides.\n\n' +
|
|
991
|
-
'Press any key to close.',
|
|
992
|
-
buttons: [{ label: 'Close', focused: true }],
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
});
|
|
1037
|
+
app.render(({ ui, theme: t, height }) => renderFinancesTui(ui, state, t, { height, interval, markdownText: hqtui.markdownText }));
|
|
996
1038
|
|
|
997
1039
|
app.on('exit', () => {
|
|
998
1040
|
clearInterval(poll);
|