@profullstack/coinpay 0.11.1 → 0.12.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 +24 -0
- package/README.md +26 -0
- package/bin/coinpay.js +24 -0
- package/package.json +1 -1
- package/src/finances-commands.js +127 -1
- package/src/finances-reports.js +16 -0
- package/src/finances-tui.js +232 -140
- package/src/mcp.js +152 -0
- package/src/statements-fetch.js +959 -0
- package/src/x402.js +12 -10
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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.12.0] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay finances statements banks|login|fetch|assist|coverage|runs|local|retry`:
|
|
12
|
+
download the original PDF statements behind every linked account from the
|
|
13
|
+
banks themselves (SimpleFIN has none) and import them into the statement
|
|
14
|
+
library with their account and period. Bank sessions stay in local Chrome
|
|
15
|
+
profiles; `fetch` exits 3 when a bank needs signing in again.
|
|
16
|
+
- `coinpay mcp`: a stdio MCP server with finance and statement tools,
|
|
17
|
+
including `statements_fetch`.
|
|
18
|
+
- SDK: `reportStatementFetchRun`, `listStatementFetchRuns`, `getStatementCoverage`.
|
|
19
|
+
|
|
20
|
+
## [0.11.2] - 2026-09-16
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Invoice creation and due dates remain visible in the finance TUI table;
|
|
24
|
+
selected invoices show created, due, sent and paid dates in the detail panel.
|
|
25
|
+
- Right-side details for selected accounts, ledger rows, crypto and card
|
|
26
|
+
payments, invoices, escrows and debt accounts.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Row clicks select the correct record after scrolling or resizing. Keyboard
|
|
30
|
+
navigation follows the last selected invoice or escrow table.
|
|
31
|
+
|
|
8
32
|
## [0.11.0] - 2026-09-12
|
|
9
33
|
|
|
10
34
|
### Added
|
package/README.md
CHANGED
|
@@ -758,6 +758,32 @@ const snapshot = await collectFinanceSnapshot(client, { days: 30 });
|
|
|
758
758
|
console.log(snapshot.earnings.netUsd, snapshot.bank.liabilities, snapshot.invoices.totals.overdue);
|
|
759
759
|
```
|
|
760
760
|
|
|
761
|
+
#### Statements from the banks
|
|
762
|
+
|
|
763
|
+
SimpleFIN carries transactions, not the PDF statements. The CLI downloads
|
|
764
|
+
them from each bank on your machine and files them in the statement library:
|
|
765
|
+
|
|
766
|
+
```bash
|
|
767
|
+
coinpay finances statements login chase # once per bank, in a window; no password is stored
|
|
768
|
+
coinpay finances statements fetch # every new statement, imported with its account and period
|
|
769
|
+
coinpay finances statements coverage # which account-months are still missing
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Each bank's session lives in a local Chrome profile under
|
|
773
|
+
`~/.coinpay/statements`. `fetch` exits 3 when a bank needs signing in again.
|
|
774
|
+
Needs Node 22+ and Chrome or Chromium. See
|
|
775
|
+
[docs/STATEMENTS.md](https://github.com/profullstack/coinpayportal/blob/master/docs/STATEMENTS.md).
|
|
776
|
+
|
|
777
|
+
### MCP
|
|
778
|
+
|
|
779
|
+
```bash
|
|
780
|
+
claude mcp add coinpay -- coinpay mcp
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
`coinpay mcp` is a stdio MCP server using your `coinpay login` session. Tools:
|
|
784
|
+
`finance_accounts`, `statements_list`, `statements_coverage`,
|
|
785
|
+
`statements_fetch_runs`, `statements_banks` and `statements_fetch`.
|
|
786
|
+
|
|
761
787
|
---
|
|
762
788
|
|
|
763
789
|
## Webhook Integration
|
package/bin/coinpay.js
CHANGED
|
@@ -164,6 +164,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
164
164
|
// finances reports / statements
|
|
165
165
|
'wait',
|
|
166
166
|
'strict',
|
|
167
|
+
'headed',
|
|
167
168
|
'setup-token-stdin',
|
|
168
169
|
'overwrite',
|
|
169
170
|
'no-pending',
|
|
@@ -483,6 +484,13 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
483
484
|
statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
|
|
484
485
|
statements list|get|download|reconcile|delete
|
|
485
486
|
Statement library; reconcile takes --report --opening --closing --currency
|
|
487
|
+
statements banks Linked banks: signed in for statement downloads? last fetch?
|
|
488
|
+
statements login <bank> Sign in once in a window (no password is stored; Chrome profile on this machine)
|
|
489
|
+
statements fetch [bank…] Download every new PDF statement from the banks and import it
|
|
490
|
+
(--since YYYY-MM, --max N, --headed, --render S; exits 3 if a bank needs you)
|
|
491
|
+
statements assist <bank> A window: every PDF you download is imported
|
|
492
|
+
statements coverage Which account-months have a statement (--months 12)
|
|
493
|
+
statements runs|local|retry Fetch history, the local archive, re-import what failed
|
|
486
494
|
books [queue] Transactions awaiting category review (--status, --scope, --search)
|
|
487
495
|
books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
|
|
488
496
|
books confirm-all Accept every pending suggestion (--yes)
|
|
@@ -534,6 +542,8 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
534
542
|
|
|
535
543
|
${colors.bright}self${colors.reset}
|
|
536
544
|
update Update the coinpay CLI to the latest version
|
|
545
|
+
mcp Serve CoinPay finances to an AI agent over MCP (stdio):
|
|
546
|
+
claude mcp add coinpay -- coinpay mcp
|
|
537
547
|
upgrade Alias for update
|
|
538
548
|
remove Uninstall the coinpay CLI
|
|
539
549
|
uninstall Alias for remove
|
|
@@ -4566,6 +4576,20 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4566
4576
|
await handleFinances(subcommand, args, flags);
|
|
4567
4577
|
break;
|
|
4568
4578
|
|
|
4579
|
+
case 'mcp': {
|
|
4580
|
+
// stdout is the protocol channel: nothing else may print to it.
|
|
4581
|
+
const { serveStdio } = await import('../src/mcp.js');
|
|
4582
|
+
await serveStdio({
|
|
4583
|
+
version: VERSION,
|
|
4584
|
+
getClient: async () => {
|
|
4585
|
+
const token = process.env.COINPAY_SESSION_TOKEN || loadConfig().jwtToken;
|
|
4586
|
+
if (!token) throw new Error('Not logged in: run `coinpay login` (finance tools need the merchant session)');
|
|
4587
|
+
return new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 });
|
|
4588
|
+
},
|
|
4589
|
+
});
|
|
4590
|
+
break;
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4569
4593
|
case 'tui':
|
|
4570
4594
|
case 'dashboard':
|
|
4571
4595
|
await handleFinances('tui', [subcommand, ...args].filter(Boolean), flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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
|
@@ -123,6 +123,131 @@ function describeReport(report) {
|
|
|
123
123
|
return parts.filter(Boolean).join(' · ');
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry']);
|
|
127
|
+
|
|
128
|
+
function ago(iso) {
|
|
129
|
+
if (!iso) return 'never';
|
|
130
|
+
const hours = (Date.now() - Date.parse(iso)) / 3_600_000;
|
|
131
|
+
if (hours < 1) return 'just now';
|
|
132
|
+
return hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Downloading statements from the banks themselves (SimpleFIN has no
|
|
137
|
+
* documents). Runs on this machine: each bank's session lives in a local
|
|
138
|
+
* Chrome profile under ~/.coinpay/statements and never reaches CoinPay.
|
|
139
|
+
* `fetch` exits 3 when any bank needs signing in again or showed nothing.
|
|
140
|
+
*/
|
|
141
|
+
async function runStatementFetchAction(action, rest, flags, { client, out, emit, progress }) {
|
|
142
|
+
const sf = await import('./statements-fetch.js');
|
|
143
|
+
const home = sf.statementsHome();
|
|
144
|
+
const chrome = typeof flags.chrome === 'string' ? flags.chrome : sf.findChrome();
|
|
145
|
+
const months = (value, fallback) => {
|
|
146
|
+
if (value === undefined) return fallback;
|
|
147
|
+
const n = Number(value);
|
|
148
|
+
if (!Number.isInteger(n) || n < 1 || n > 36) throw new CliExit(EXIT.INVALID, '--months is a whole number from 1 to 36');
|
|
149
|
+
return n;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (action === 'coverage') {
|
|
154
|
+
const data = await api.getStatementCoverage(client, { months: months(flags.months, 12) });
|
|
155
|
+
const mark = { have: '■', missing: '·', open: '○' };
|
|
156
|
+
const lines = data.accounts.map((a) => `${a.months.map((m) => mark[m.state]).join('')} ${a.institutionKey} ${a.accountName}${a.missing ? ` (${a.missing} missing)` : ''}`);
|
|
157
|
+
const fetchers = (data.fetchers || []).map((r) => `${r.institutionKey}: ${r.status} ${ago(r.finishedAt)}${r.message ? ` (${r.message})` : ''}`);
|
|
158
|
+
emit(data, [`${data.months[0]} … ${data.months[data.months.length - 1]} ■ have · missing ○ this month`, ...lines, '', `${data.missing} account-months missing`, ...(fetchers.length ? ['', 'Last fetch per bank:', ...fetchers] : ['', 'No statement fetch has run yet: coinpay finances statements banks'])].join('\n'));
|
|
159
|
+
return EXIT.OK;
|
|
160
|
+
}
|
|
161
|
+
if (action === 'runs') {
|
|
162
|
+
const data = await api.listStatementFetchRuns(client, { limit: flags.limit ? Number(flags.limit) : undefined });
|
|
163
|
+
emit(data, data.runs.length ? data.runs.map((r) => `${r.finishedAt} ${r.institutionKey} ${r.status} ${r.filed} imported, ${r.duplicates} had, ${r.unmatched} unmatched${r.message ? ` ${r.message}` : ''}`).join('\n') : 'No statement fetch has run yet.');
|
|
164
|
+
return EXIT.OK;
|
|
165
|
+
}
|
|
166
|
+
if (action === 'local') {
|
|
167
|
+
const state = sf.loadLocal(home);
|
|
168
|
+
emit({ home, entries: state.entries }, state.entries.length
|
|
169
|
+
? state.entries.map((e) => `${e.statementId ? 'imported ' : 'local '} ${e.path}${e.importError ? ` (${e.importError})` : ''}`).join('\n')
|
|
170
|
+
: `Nothing downloaded yet (${home})`);
|
|
171
|
+
return EXIT.OK;
|
|
172
|
+
}
|
|
173
|
+
if (action === 'retry') {
|
|
174
|
+
const result = await sf.retryImports({ api: await sf.clientApi(client), home, log: progress });
|
|
175
|
+
emit(result, `${result.imported} imported, ${result.skipped} skipped (no account or period), ${result.failed} failed`);
|
|
176
|
+
return result.failed ? EXIT.FAILURE : EXIT.OK;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const bound = await sf.clientApi(client);
|
|
180
|
+
const institutions = sf.groupInstitutions(await bound.listAccounts());
|
|
181
|
+
if (action === 'banks') {
|
|
182
|
+
const state = sf.loadLocal(home);
|
|
183
|
+
let latest = [];
|
|
184
|
+
try {
|
|
185
|
+
latest = (await api.listStatementFetchRuns(client, { limit: 200 })).latest || [];
|
|
186
|
+
} catch {
|
|
187
|
+
// An older deployment without the route still lists the banks.
|
|
188
|
+
}
|
|
189
|
+
const rows = institutions.map((i) => ({ ...i, signedIn: sf.signedIn(i.key, home), local: state.institutions[i.key] || {}, lastRun: latest.find((r) => r.institutionKey === i.key) || null }));
|
|
190
|
+
emit({ home, chrome, banks: rows }, [
|
|
191
|
+
...rows.map((b) => `${b.key} ${b.name} · ${b.signedIn ? `signed in ${ago(b.local.loggedInAt)}` : 'not signed in'} · last fetch ${b.lastRun ? `${b.lastRun.status} ${ago(b.lastRun.finishedAt)}` : 'never'}\n${b.accounts.map((a) => ` ${a.name}${a.last4 ? '' : ' (no last four digits: its statements may need filing by hand)'}`).join('\n')}`),
|
|
192
|
+
...(rows.some((b) => !b.signedIn) ? ['', `Next: coinpay finances statements login ${rows.find((b) => !b.signedIn).key}`] : []),
|
|
193
|
+
...(chrome ? [] : ['', sf.NO_CHROME]),
|
|
194
|
+
].join('\n'));
|
|
195
|
+
return EXIT.OK;
|
|
196
|
+
}
|
|
197
|
+
if (action === 'login' || action === 'assist') {
|
|
198
|
+
if (rest.length !== 1) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances statements ${action} <bank>`);
|
|
199
|
+
if (!chrome) throw new CliExit(EXIT.FAILURE, sf.NO_CHROME);
|
|
200
|
+
const institution = sf.pickInstitution(institutions, rest[0]);
|
|
201
|
+
const state = sf.loadLocal(home);
|
|
202
|
+
const local = (state.institutions[institution.key] ||= {});
|
|
203
|
+
const urls = sf.startUrls(institution, local.start);
|
|
204
|
+
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false });
|
|
205
|
+
if (action === 'login') {
|
|
206
|
+
const url = typeof flags.url === 'string' ? flags.url : urls.login;
|
|
207
|
+
if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
|
|
208
|
+
out(`Opening ${institution.name}. Sign in, go to the page that lists your statements, then close the window.`);
|
|
209
|
+
const last = await sf.loginWindow(browser, url);
|
|
210
|
+
await browser.close();
|
|
211
|
+
local.loggedInAt = new Date().toISOString();
|
|
212
|
+
if (last) local.start = last;
|
|
213
|
+
sf.saveLocal(state, home);
|
|
214
|
+
emit({ bank: institution.key, start: local.start || urls.fetch }, last ? `Saved. fetch will start at ${last}` : 'Saved. The window closed on a sign-in page, so fetch starts from the bank\'s known page.');
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
out(`Opening ${institution.name}. Download each statement you want; each is imported as it lands. Close the window when done.`);
|
|
218
|
+
let kept = 0;
|
|
219
|
+
try {
|
|
220
|
+
await sf.assistWindow(browser, {
|
|
221
|
+
start: urls.fetch,
|
|
222
|
+
onFile: async (download) => {
|
|
223
|
+
const result = await sf.keepStatement({ institution, download, state, home, importStatement: bound.importStatement, how: 'assist' });
|
|
224
|
+
if (result.status === 'imported') kept += 1;
|
|
225
|
+
progress(` ${result.status}: ${result.entry ? result.entry.path : result.name}${result.entry && result.entry.importError ? ` (${result.entry.importError})` : ''}`);
|
|
226
|
+
sf.saveLocal(state, home);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
await browser.close();
|
|
231
|
+
}
|
|
232
|
+
emit({ bank: institution.key, imported: kept }, `${kept} imported`);
|
|
233
|
+
return EXIT.OK;
|
|
234
|
+
}
|
|
235
|
+
// fetch
|
|
236
|
+
const since = typeof flags.since === 'string' ? flags.since : null;
|
|
237
|
+
if (since && !/^\d{4}-(0[1-9]|1[0-2])$/.test(since)) throw new CliExit(EXIT.INVALID, '--since is YYYY-MM');
|
|
238
|
+
const max = flags.max !== undefined ? Number(flags.max) : 24;
|
|
239
|
+
if (!Number.isInteger(max) || max < 1 || max > 500) throw new CliExit(EXIT.INVALID, '--max is a whole number from 1 to 500');
|
|
240
|
+
const render = flags.render !== undefined ? Number(flags.render) : 25;
|
|
241
|
+
if (!Number.isFinite(render) || render < 3 || render > 300) throw new CliExit(EXIT.INVALID, '--render is seconds, 3 to 300');
|
|
242
|
+
const results = await sf.runStatementFetch({ api: bound, banks: rest, since, max, renderMs: render * 1000, headless: flags.headed !== true, chrome, home, log: progress });
|
|
243
|
+
emit({ banks: results }, results.map((r) => `${r.bank}: ${r.status}, ${r.imported} imported, ${r.duplicates} already had, ${r.unmatched} unmatched${r.message ? ` (${r.message})` : ''}`).join('\n'));
|
|
244
|
+
return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err instanceof sf.StatementFetchError) throw new CliExit(EXIT.INVALID, err.message);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
126
251
|
/**
|
|
127
252
|
* Run one subcommand. `ctx` supplies the client and the output channels.
|
|
128
253
|
* @returns {Promise<number>} an exit code
|
|
@@ -414,7 +539,8 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
414
539
|
emit(data, data.note);
|
|
415
540
|
return EXIT.OK;
|
|
416
541
|
}
|
|
417
|
-
|
|
542
|
+
if (STATEMENT_FETCH_ACTIONS.has(action)) return await runStatementFetchAction(action, args.slice(1), flags, { client, out, emit, progress });
|
|
543
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>|banks|login <bank>|fetch [bank…]|assist <bank>|coverage|runs|local|retry]');
|
|
418
544
|
}
|
|
419
545
|
|
|
420
546
|
case 'books': {
|
package/src/finances-reports.js
CHANGED
|
@@ -256,6 +256,22 @@ export async function reconcileFinanceStatement(client, statementId, {
|
|
|
256
256
|
return data.reconciliation;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** Report one bank's statement fetch run (counts only). */
|
|
260
|
+
export async function reportStatementFetchRun(client, run) {
|
|
261
|
+
const data = await call(client, '/finances/statements/fetch-runs', { method: 'POST', body: JSON.stringify(run) });
|
|
262
|
+
return data.run;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `{latest, runs}`: the newest fetch run per bank, and the recent history. */
|
|
266
|
+
export async function listStatementFetchRuns(client, { limit } = {}) {
|
|
267
|
+
return call(client, `/finances/statements/fetch-runs${query({ limit })}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Which of the last `months` months have a statement, per account, plus each bank's latest fetch. */
|
|
271
|
+
export async function getStatementCoverage(client, { months } = {}) {
|
|
272
|
+
return call(client, `/finances/statements/coverage${query({ months })}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
259
275
|
// ── Books ──
|
|
260
276
|
|
|
261
277
|
/** Rows awaiting review (default) plus the category vocabularies. */
|