@profullstack/coinpay 0.13.0 → 0.16.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.
@@ -256,6 +256,43 @@ export async function reconcileFinanceStatement(client, statementId, {
256
256
  return data.reconciliation;
257
257
  }
258
258
 
259
+ // ── Document library ──
260
+
261
+ /**
262
+ * File a document (multipart POST /finances/documents). `file` is the bytes.
263
+ * Tax documents from the fetcher pass `category: 'tax'`, `source: 'fetch'`,
264
+ * `institutionKey`, `taxYear` and `docType`. Answers `{document, duplicate}`:
265
+ * the server keeps one copy per file (by sha256) and says when it had it.
266
+ */
267
+ export async function uploadFinanceDocument(client, {
268
+ file, filename = 'document.pdf', title, category, periodLabel, notes, contentType = 'application/pdf', source, institutionKey, taxYear, docType,
269
+ } = {}) {
270
+ if (!file) throw new Error('A file is required');
271
+ const form = new FormData();
272
+ const blob = file instanceof Blob ? file : new Blob([file], { type: contentType });
273
+ form.append('file', blob, filename);
274
+ form.append('title', title || filename);
275
+ if (category) form.append('category', category);
276
+ if (periodLabel) form.append('period', periodLabel);
277
+ if (notes) form.append('notes', notes);
278
+ if (source) form.append('source', source);
279
+ if (institutionKey) form.append('institutionKey', institutionKey);
280
+ if (taxYear !== undefined && taxYear !== null) form.append('taxYear', String(taxYear));
281
+ if (docType) form.append('docType', docType);
282
+ return client.requestForm('/finances/documents', form);
283
+ }
284
+
285
+ /** The document library, newest first; `category: 'tax'` for FTB/IRS notices, letters and transcripts. */
286
+ export async function listFinanceDocuments(client, { category, limit } = {}) {
287
+ const data = await call(client, `/finances/documents${query({ category, limit })}`);
288
+ return data.documents || [];
289
+ }
290
+
291
+ export async function getFinanceDocument(client, documentId) {
292
+ const data = await call(client, `/finances/documents/${encodeURIComponent(documentId)}`);
293
+ return data.document;
294
+ }
295
+
259
296
  /** Report one bank's statement fetch run (counts only). */
260
297
  export async function reportStatementFetchRun(client, run) {
261
298
  const data = await call(client, '/finances/statements/fetch-runs', { method: 'POST', body: JSON.stringify(run) });
@@ -351,9 +388,13 @@ export async function getBooksSummary(client, { period, from, to, timezone, scop
351
388
  return call(client, `/finances/books/summary${query({ period, from, to, timezone, scope, rows: rows ? 1 : undefined })}`);
352
389
  }
353
390
 
354
- /** The CPA pack as bytes. */
355
- export async function exportBooks(client, { period, from, to, timezone, scope, format = 'csv' } = {}) {
356
- return client.requestBinary(`/finances/books/export${query({ period, from, to, timezone, scope, format })}`);
391
+ /**
392
+ * The CPA pack as bytes. With `withDocuments` the answer is a ZIP: the pack
393
+ * plus the period's tax documents that fit in 8 MiB, and MANIFEST.txt naming
394
+ * what was left out.
395
+ */
396
+ export async function exportBooks(client, { period, from, to, timezone, scope, format = 'csv', withDocuments = false } = {}) {
397
+ return client.requestBinary(`/finances/books/export${query({ period, from, to, timezone, scope, format, with_documents: withDocuments ? 1 : undefined })}`);
357
398
  }
358
399
 
359
400
  // ── Raw provider payloads ──
@@ -377,10 +418,10 @@ export async function sendFinanceReportEmail(client, reportId, { to, formats, me
377
418
  }
378
419
 
379
420
  /** Email the CPA pack for a period. `toDate` is the exclusive end for a custom range. */
380
- export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays } = {}) {
421
+ export async function sendBooksEmail(client, { to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays, withDocuments } = {}) {
381
422
  return call(client, '/finances/books/send', {
382
423
  method: 'POST',
383
- body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays }),
424
+ body: JSON.stringify({ to, period, from, toDate, timezone, scope, formats, message, attach, expiresInDays, withDocuments: withDocuments === true ? true : undefined }),
384
425
  });
385
426
  }
386
427
 
@@ -404,3 +445,42 @@ export async function deleteWeeklyDigest(client) {
404
445
  export async function sendWeeklyDigestNow(client) {
405
446
  return call(client, '/finances/email-schedules/send-now', { method: 'POST', body: '{}' });
406
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/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).',
@@ -60,9 +142,39 @@ export const TOOLS = [
60
142
  return text({
61
143
  chrome: sf.findChrome(),
62
144
  banks: institutions.map((i) => ({ key: i.key, name: i.name, accounts: i.accounts.map((a) => a.name), signedIn: sf.signedIn(i.key, home), ...state.institutions[i.key] })),
145
+ // FTB and IRS need no linked bank; each is throttled (2 visits / 30 min, 4 / day).
146
+ taxSources: sf.standaloneSources().filter((s) => s.kind === 'tax').map((s) => ({ key: s.key, name: s.name, signedIn: sf.signedIn(s.key, home), throttle: sf.checkLocalThrottle(home, s.key), ...state.institutions[s.key] })),
147
+ // Brokerages with no linked account (Webull): statements, confirmations and 1099s go to Documents.
148
+ brokerages: sf.standaloneSources().filter((s) => s.kind === 'brokerage' && !institutions.some((i) => i.key === s.key)).map((s) => ({ key: s.key, name: s.name, signedIn: sf.signedIn(s.key, home), ...state.institutions[s.key] })),
63
149
  });
64
150
  },
65
151
  },
152
+ {
153
+ name: 'tax_documents_list',
154
+ description:
155
+ 'Tax documents in the CoinPay document library: notices, letters and transcripts fetched from California FTB and the IRS (or uploaded), with tax year, type, source and period. Details only, not the files. Optionally one tax year or one source (ftb, irs, irs-business).',
156
+ inputSchema: {
157
+ type: 'object',
158
+ properties: {
159
+ taxYear: { type: 'integer', minimum: 1990, maximum: 2100 },
160
+ source: { type: 'string', description: 'ftb, irs or irs-business' },
161
+ limit: { type: 'integer', minimum: 1, maximum: 500 },
162
+ },
163
+ additionalProperties: false,
164
+ },
165
+ handler: async (args, { client }) => {
166
+ const documents = (await reports.listFinanceDocuments(client, { category: 'tax', limit: args.limit }))
167
+ .filter((d) => args.taxYear === undefined || d.taxYear === args.taxYear)
168
+ .filter((d) => args.source === undefined || d.institutionKey === args.source);
169
+ return text({ documents });
170
+ },
171
+ },
172
+ {
173
+ name: 'tax_documents_get',
174
+ description: 'One document from the CoinPay document library by id: title, type, tax year, source, period, size, and its download path in CoinPay.',
175
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false },
176
+ handler: async (args, { client }) => text({ document: await reports.getFinanceDocument(client, args.id) }),
177
+ },
66
178
  {
67
179
  name: 'statements_cloud_status',
68
180
  description:
@@ -121,7 +233,7 @@ export async function handleMessage(message, ctx) {
121
233
  capabilities: { tools: {} },
122
234
  serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
123
235
  instructions:
124
- '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>`.',
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.',
125
237
  });
126
238
  case 'notifications/initialized':
127
239
  case 'notifications/cancelled':
@@ -129,7 +241,9 @@ export async function handleMessage(message, ctx) {
129
241
  case 'ping':
130
242
  return reply({});
131
243
  case 'tools/list':
132
- return reply({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
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' });
133
247
  case 'tools/call': {
134
248
  const tool = TOOLS.find((t) => t.name === params.name);
135
249
  if (!tool) return fail(-32602, `Unknown tool: ${params.name}`);
@@ -14,6 +14,52 @@ export interface Institution {
14
14
  name: string;
15
15
  url: string | null;
16
16
  accounts: FetcherAccount[];
17
+ /** 'tax' for an agency (FTB, IRS), 'brokerage' for a brokerage with no linked account (Webull); absent for a bank. */
18
+ kind?: 'tax' | 'brokerage';
19
+ }
20
+
21
+ export interface TaxSource {
22
+ key: string;
23
+ kind: 'tax';
24
+ name: string;
25
+ login: string;
26
+ lockoutMinutes: number;
27
+ cloudWarning?: string;
28
+ }
29
+
30
+ export interface BrokerageSource {
31
+ key: string;
32
+ kind: 'brokerage';
33
+ name: string;
34
+ login: string;
35
+ statements?: string;
36
+ }
37
+
38
+ export interface BrokerageDocumentInfo {
39
+ category: 'statement' | 'tax';
40
+ docType: 'form' | null;
41
+ taxYear: number | null;
42
+ periodLabel: string;
43
+ title: string;
44
+ }
45
+
46
+ export interface ThrottleLimits {
47
+ windowMs: number;
48
+ perWindow: number;
49
+ perDay: number;
50
+ defaultLockoutMinutes: number;
51
+ marginMinutes: number;
52
+ }
53
+
54
+ export type ThrottleVerdict = { ok: true } | { ok: false; reason: 'locked' | 'daily_limit' | 'window_limit'; retryAt: string; message: string };
55
+
56
+ export interface TaxDocumentInfo {
57
+ docType: 'transcript' | 'notice' | 'letter' | 'form' | 'return' | 'other';
58
+ code: string | null;
59
+ taxYear: number | null;
60
+ noticeDate: string | null;
61
+ periodLabel: string;
62
+ title: string;
17
63
  }
18
64
 
19
65
  export interface Period {
@@ -55,7 +101,7 @@ export interface Download {
55
101
  key: string | null;
56
102
  }
57
103
 
58
- export type PageStatus = 'ok' | 'login_needed' | 'no_statements';
104
+ export type PageStatus = 'ok' | 'login_needed' | 'no_statements' | 'locked';
59
105
 
60
106
  export function lastFour(name: string): string | null;
61
107
  export function slug(value: string): string;
@@ -63,6 +109,37 @@ export function institutionKey(domain: string | null | undefined, name: string |
63
109
  export function groupInstitutions(accounts: ReadonlyArray<{ id: string; name: string; org_name?: string | null; org_domain?: string | null; is_hidden?: boolean }>): Institution[];
64
110
  export function pickInstitution(institutions: readonly Institution[], name: string): Institution;
65
111
  export const DRIVERS: ReadonlyArray<{ key: string; login: string; statements?: string }>;
112
+ export const HOST_KEYS: ReadonlyArray<[RegExp, string]>;
113
+ export const TAX_SOURCES: readonly TaxSource[];
114
+ export function taxSource(key: string): TaxSource | null;
115
+ export const BROKERAGE_SOURCES: readonly BrokerageSource[];
116
+ export function brokerageSource(key: string): BrokerageSource | null;
117
+ export function isBrokerageSource(institution: { key: string; kind?: string; accounts?: readonly unknown[] } | null | undefined): boolean;
118
+ export function sourceMode(institution: { key: string; kind?: string; accounts?: readonly unknown[] }): 'statements' | 'tax' | 'documents';
119
+ export function standaloneSources(): Institution[];
120
+ export function withStandaloneSources(institutions: readonly Institution[]): Institution[];
121
+ export function isTaxSource(institution: { key: string; kind?: string; accounts?: readonly unknown[] } | null | undefined): boolean;
122
+ export function keepCandidate(mode: 'statements' | 'tax' | 'documents', label: string, context: string, href: string | null): boolean;
123
+ export function collectScript(mode?: 'statements' | 'tax' | 'documents'): string;
124
+ export const COLLECT_TAX: string;
125
+ export const OPEN_TAX_DOCS: string;
126
+ export const COLLECT_DOCS: string;
127
+ export const OPEN_DOCUMENTS: string;
128
+ export const PAGE_TEXT: string;
129
+ export function classifyTaxDocument(download: { label?: string; context?: string; suggestedName?: string }, fetchedAt?: Date): TaxDocumentInfo;
130
+ export function keepTaxDocument(options: Record<string, unknown>): Promise<Record<string, unknown>>;
131
+ export function classifyBrokerageDocument(download: { label?: string; context?: string; suggestedName?: string }, fetchedAt?: Date): BrokerageDocumentInfo;
132
+ export function keepBrokerageDocument(options: Record<string, unknown>): Promise<Record<string, unknown>>;
133
+ export const TAX_THROTTLE: ThrottleLimits;
134
+ export function evaluateThrottle(source: { attempts?: ReadonlyArray<string | { at: string }>; lockedUntil?: string | null; lockReason?: string | null }, now?: Date, limits?: ThrottleLimits): ThrottleVerdict;
135
+ export function detectLockout(text: string | null | undefined): { minutes: number | null } | null;
136
+ export function lockoutUntil(found: { minutes: number | null } | null, now?: Date, limits?: ThrottleLimits, defaultMinutes?: number): string;
137
+ export function throttlePath(home: string): string;
138
+ export function loadThrottle(home: string): { sources: Record<string, { attempts: Array<{ at: string; kind: string }>; lockedUntil: string | null; lockReason: string | null }> };
139
+ export function saveThrottle(ledger: unknown, home: string): void;
140
+ export function checkLocalThrottle(home: string, key: string, now?: Date, limits?: ThrottleLimits): ThrottleVerdict;
141
+ export function takeLocalAttempt(home: string, key: string, kind: string, now?: Date, limits?: ThrottleLimits): ThrottleVerdict;
142
+ export function recordLocalLockout(home: string, key: string, until: string, reason?: string): unknown;
66
143
  export function startUrls(institution: { key: string; url: string | null }, learnt?: string | null): { login: string | null; fetch: string | null };
67
144
  export function isSignInUrl(url: string): boolean;
68
145
  export function findDates(source: string | null | undefined): { date: string; precise: boolean }[];
@@ -106,10 +183,13 @@ export function fetchInstitution(
106
183
  renderMs?: number;
107
184
  onFile: (download: Download) => Promise<void> | void;
108
185
  log?: (line: string) => void;
186
+ mode?: 'statements' | 'tax' | 'documents';
187
+ watchLockout?: boolean;
188
+ pauseMs?: number;
109
189
  },
110
- ): Promise<{ status: PageStatus; url: string; candidates: number; silent: string[] }>;
111
- export function loginWindow(browser: Browser, url: string): Promise<string | null>;
112
- export function assistWindow(browser: Browser, options: { start: string; onFile: (download: Download) => Promise<void> | void }): Promise<number>;
190
+ ): Promise<{ status: PageStatus; url: string; candidates: number; silent: string[]; lockout?: { minutes: number | null } }>;
191
+ export function loginWindow(browser: Browser, url: string, options?: { onPageText?: (text: string) => unknown }): Promise<string | null>;
192
+ export function assistWindow(browser: Browser, options: { start: string; onFile: (download: Download) => Promise<void> | void; onPageText?: (text: string) => unknown }): Promise<number>;
113
193
  export function loadLocal(home?: string): { institutions: Record<string, Record<string, unknown>>; entries: Record<string, unknown>[] };
114
194
  export function saveLocal(state: unknown, home?: string): void;
115
195
  export function archive(home: string, institution: Institution, account: FetcherAccount | null, period: Period | null, suggestedName: string, bytes: Uint8Array): string;
@@ -119,5 +199,6 @@ export function clientApi(client: unknown): Promise<{
119
199
  listAccounts(): Promise<unknown[]>;
120
200
  importStatement(options: Record<string, unknown>): Promise<unknown>;
121
201
  reportRun(run: Record<string, unknown>): Promise<unknown>;
202
+ fileDocument(options: Record<string, unknown>): Promise<{ document?: { id: string }; duplicate?: boolean }>;
122
203
  }>;
123
204
  export function retryImports(options: Record<string, unknown>): Promise<{ imported: number; skipped: number; failed: number }>;