@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 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.11.1",
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",
@@ -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
- throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>]');
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': {
@@ -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. */