@profullstack/coinpay 0.25.0 → 0.27.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,38 @@ 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.27.0] - 2026-10-28
9
+
10
+ ### Added
11
+ - Finance goals: save for a house or a car, build an emergency fund, or pay off a debt, by a deadline.
12
+ `coinpay finances goals create --name "Down payment" --kind house --target 60000 --deadline 2031-06-30`
13
+ (`--account <id>` / `--wallet <id>` link real balances; `--start-amount` is what is already saved, or
14
+ the debt at the start of a payoff goal); `goals list|show|contribute <id> <amount>|sources|archive|delete`.
15
+ Each goal says on track, behind, achieved or past deadline, and what it takes per day/week/month/quarter
16
+ to finish on time.
17
+ - `coinpay finances goals report --period daily|weekly|monthly|quarterly`: each goal's change over the
18
+ trailing period, what the period needed to stay on schedule, and the pace.
19
+ - `coinpay finances goals digest on --cadence monthly`: the opt-in goals report email (daily, weekly,
20
+ monthly on the 1st, quarterly on Jan/Apr/Jul/Oct 1st); `digest show|off|send-now`.
21
+ - SDK `listGoals`, `getGoal`, `createGoal`, `updateGoal`, `deleteGoal`, `addGoalContribution`,
22
+ `getGoalSources`, `getGoalsReport`, `getGoalsDigest`, `setGoalsDigest`, `deleteGoalsDigest`,
23
+ `sendGoalsDigestNow`; MCP `finance_goals`, `finance_goal_create`, `finance_goal_contribute`,
24
+ `finance_goals_report`, `finance_goals_digest`. Not financial advice; nothing moves money.
25
+
26
+ ## [0.26.0] - 2026-10-28
27
+
28
+ ### Added
29
+ - Declared recurring costs: bills no bank or card feed shows (servers paid by PayPal, crypto or an
30
+ unlinked card). `coinpay finances costs import <file|url> [--source opendevice] [--header "K: V"]`
31
+ takes an OpenDevice inventory (converted server-side with `@profullstack/fleet`) or `toCoinPayCosts`
32
+ rows and replaces that source's set; `coinpay finances costs list [--json]`, `costs clear --source s --yes`.
33
+ SDK `listDeclaredCosts(client, { source })`, `importDeclaredCosts(client, body, { source })`; MCP
34
+ `finance_declared_costs`, `finance_import_costs`.
35
+ - `coinpay finances recurring` (and `--json`) now includes declared costs: each charge has `origin`
36
+ `observed`, `declared` (not in any feed) or `matched` (seen in the feed and declared: payee, amount
37
+ within 10% and interval agree; counted once, as observed, with the declaration in `declared`).
38
+ `monthlyTotal` includes declared costs; `monthlyDeclared` is that part.
39
+
8
40
  ## [0.25.0] - 2026-10-21
9
41
 
10
42
  ### Added
package/bin/coinpay.js CHANGED
@@ -270,7 +270,7 @@ const BOOLEAN_FLAGS = new Set([
270
270
  * Parse command line arguments
271
271
  */
272
272
  // Flags that may be given more than once collect into an array.
273
- const REPEATABLE_FLAGS = new Set(['item']);
273
+ const REPEATABLE_FLAGS = new Set(['item', 'header']);
274
274
 
275
275
  function parseArgs(args) {
276
276
  const result = { command: null, subcommand: null, args: [], flags: {} };
@@ -722,8 +722,16 @@ ${colors.cyan}Commands:${colors.reset}
722
722
  digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
723
723
  due [--days 7] What is due and overdue across every account (--days up to 60)
724
724
  recurring Every recurring charge detected, with next date and monthly cost
725
+ (origin: observed, declared, or matched = both, counted once)
726
+ costs [list|import|clear] Declared recurring costs no feed shows (servers paid by PayPal/crypto):
727
+ import <file|url> takes an OpenDevice inventory or @profullstack/fleet
728
+ toCoinPayCosts rows (--source opendevice, --header "K: V" for a URL)
725
729
  due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
726
730
  daily [show|on|off|send-now] Daily summary email: spend vs target, top cuts, due soon, position (--hour 7 --to …, --target 100)
731
+ goals [list|show|create|contribute|report|digest|sources|archive|delete]
732
+ Savings and payoff goals (house, car, emergency fund, debt) by a deadline;
733
+ progress from linked accounts/wallets or recorded contributions.
734
+ report --period daily|weekly|monthly|quarterly · digest on --cadence monthly
727
735
 
728
736
  ${colors.bright}escrow${colors.reset}
729
737
  create Create a new escrow
@@ -5000,10 +5008,10 @@ function printPosition(position, fmt, { heading = false, detail = false } = {})
5000
5008
  { title: 'Amount', align: 'right', render: (c) => money(c.amount, cur) },
5001
5009
  { title: 'Every', align: 'left', render: (c) => c.cadence },
5002
5010
  { title: 'Seen', align: 'right', render: (c) => String(c.occurrences) },
5003
- { title: 'Last', align: 'left', render: (c) => c.lastSeen.slice(0, 10) },
5004
- { title: 'Next', align: 'left', render: (c) => c.nextExpected.slice(0, 10) },
5011
+ { title: 'Last', align: 'left', render: (c) => (c.origin === 'declared' ? '-' : c.lastSeen.slice(0, 10)) },
5012
+ { title: 'Next', align: 'left', render: (c) => (c.nextExpected || '-').slice(0, 10) },
5005
5013
  { title: 'Per month', align: 'right', render: (c) => money(c.monthlyEquivalent, cur) },
5006
- { title: 'Kind', align: 'left', render: (c) => (c.isDebtService ? 'debt' : 'bill') },
5014
+ { title: 'Kind', align: 'left', render: (c) => (c.isDebtService ? 'debt' : c.origin === 'declared' ? 'declared' : c.origin === 'matched' ? 'bill (declared)' : 'bill') },
5007
5015
  ]);
5008
5016
  }
5009
5017
  console.log('');
@@ -5253,7 +5261,10 @@ async function handleFinances(subcommand, args, flags) {
5253
5261
  case 'due-soon':
5254
5262
  case 'recurring':
5255
5263
  case 'due-day':
5256
- case 'daily': {
5264
+ case 'daily':
5265
+ case 'costs':
5266
+ case 'goals':
5267
+ case 'goal': {
5257
5268
  const { client } = financesClient();
5258
5269
  const { runFinancesCommand } = await import('../src/finances-commands.js');
5259
5270
  const code = await runFinancesCommand(subcommand, args, flags, {
@@ -5267,7 +5278,7 @@ async function handleFinances(subcommand, args, flags) {
5267
5278
 
5268
5279
  default:
5269
5280
  print.error(`Unknown finances command: ${subcommand}`);
5270
- console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|pnl|payloads|digest|due|recurring|due-day|daily]');
5281
+ console.log('Usage: coinpay finances [tui|summary|accounts|ledger|connections|sync|connect|disconnect|backfill|jobs|coverage|report|reports|statements|books|pnl|payloads|digest|due|recurring|due-day|daily|costs|goals]');
5271
5282
  process.exit(1);
5272
5283
  }
5273
5284
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.25.0",
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",
3
+ "version": "0.27.0",
4
+ "description": "CoinPay SDK & CLI — 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",
7
7
  "types": "./src/index.d.ts",
@@ -135,16 +135,16 @@
135
135
  "node": ">=20.0.0"
136
136
  },
137
137
  "devDependencies": {
138
- "eslint": "^8.57.0",
139
- "prettier": "^3.4.1",
140
- "vitest": "^4.1.11"
138
+ "eslint": "^8.57.1",
139
+ "prettier": "^3.9.10",
140
+ "vitest": "^4.1.12"
141
141
  },
142
142
  "dependencies": {
143
- "@inquirer/prompts": "^8.3.0",
144
- "@noble/curves": "^1.6.0",
145
- "@noble/hashes": "^1.5.0",
146
- "@profullstack/hqtui": "^0.6.3",
147
- "@scure/bip32": "^1.5.0",
148
- "@scure/bip39": "^1.4.0"
143
+ "@inquirer/prompts": "^8.7.4",
144
+ "@noble/curves": "^1.9.7",
145
+ "@noble/hashes": "^1.8.0",
146
+ "@profullstack/hqtui": "^0.11.1",
147
+ "@scure/bip32": "^1.7.0",
148
+ "@scure/bip39": "^1.6.0"
149
149
  }
150
150
  }
@@ -386,6 +386,37 @@ async function runStatementFetchAction(action, rest, flags, { client, out, emit,
386
386
  * Run one subcommand. `ctx` supplies the client and the output channels.
387
387
  * @returns {Promise<number>} an exit code
388
388
  */
389
+ /**
390
+ * Read a declared-costs body: a local JSON file or an http(s) URL (an
391
+ * OpenDevice inventory, or toCoinPayCosts rows). `--header "K: V"` is sent
392
+ * with a URL, e.g. for a private raw file; it may be repeated.
393
+ */
394
+ export async function readCostsInput(where, header, { fetchImpl = globalThis.fetch } = {}) {
395
+ let raw;
396
+ if (/^https?:\/\//i.test(where)) {
397
+ const headers = {};
398
+ for (const h of [].concat(header ?? []).filter((v) => typeof v === 'string')) {
399
+ const i = h.indexOf(':');
400
+ if (i <= 0) throw new CliExit(EXIT.INVALID, `--header must look like "Name: value", got ${h.slice(0, 40)}`);
401
+ headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
402
+ }
403
+ const res = await fetchImpl(where, { headers });
404
+ if (!res.ok) throw new CliExit(EXIT.FAILURE, `${where}: HTTP ${res.status}`);
405
+ raw = await res.text();
406
+ } else {
407
+ try {
408
+ raw = readFileSync(resolvePath(where), 'utf8');
409
+ } catch (e) {
410
+ throw new CliExit(EXIT.INVALID, `Cannot read ${where}: ${e.message}`);
411
+ }
412
+ }
413
+ try {
414
+ return JSON.parse(raw);
415
+ } catch {
416
+ throw new CliExit(EXIT.INVALID, `${where} is not JSON`);
417
+ }
418
+ }
419
+
389
420
  export async function runFinancesCommand(subcommand, args, flags, ctx) {
390
421
  const { client, out, err: errOut } = ctx;
391
422
  const json = flags.json === true;
@@ -881,10 +912,12 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
881
912
  const money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n);
882
913
  if (subcommand === 'recurring') {
883
914
  if (json) { out(JSON.stringify(d.recurring, null, 2)); return EXIT.OK; }
884
- const rows = [...d.recurring.charges].sort((a, b) => a.nextExpected.localeCompare(b.nextExpected));
915
+ const rows = [...d.recurring.charges].sort((a, b) => (a.nextExpected || '9999').localeCompare(b.nextExpected || '9999'));
885
916
  out(`${rows.length} recurring charges · ${money(d.recurring.monthlyTotal)}/mo${d.recurring.monthlyDebtService ? ` (${money(d.recurring.monthlyDebtService)} debt service)` : ''}`);
917
+ if (d.recurring.monthlyDeclared) out(` of which ${money(d.recurring.monthlyDeclared)}/mo is declared (not in any feed)`);
886
918
  for (const c of rows) {
887
- out(` next ${c.nextExpected.slice(0, 10)} ${c.cadence.padEnd(9)} ${money(c.amount).padStart(11)} ${c.payee} · ${c.accountName}${c.isDebtService ? ' · debt' : ''}`);
919
+ const origin = c.origin === 'declared' ? ' · declared' : c.origin === 'matched' ? ' · matched' : '';
920
+ out(` next ${(c.nextExpected || '?').slice(0, 10).padEnd(10)} ${c.cadence.padEnd(9)} ${money(c.amount).padStart(11)} ${c.payee} · ${c.accountName}${c.isDebtService ? ' · debt' : ''}${origin}`);
888
921
  }
889
922
  return EXIT.OK;
890
923
  }
@@ -900,6 +933,158 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
900
933
  return EXIT.OK;
901
934
  }
902
935
 
936
+ case 'costs': {
937
+ const action = args[0] || 'list';
938
+ const source = typeof flags.source === 'string' ? flags.source : undefined;
939
+ const fmt = (cur, n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n);
940
+ const describe = (data) => {
941
+ const lines = data.costs.map((c) => ` ${c.source.padEnd(11)} ${c.key.padEnd(22)} ${fmt(c.currency, c.amount).padStart(11)} ${c.cadence.padEnd(8)} ${c.payee}${c.paidWith ? ` · ${c.paidWith}` : ''}${c.nextExpected ? ` · next ${c.nextExpected.slice(0, 10)}` : ''}`);
942
+ const totals = Object.entries(data.monthly || {}).map(([cur, n]) => `${fmt(cur, n)}/mo`).join(' + ') || 'nothing';
943
+ return [`${data.costs.length} declared cost(s) · ${totals}`, ...lines].join('\n');
944
+ };
945
+ if (action === 'list') {
946
+ const data = await api.listDeclaredCosts(client, { source });
947
+ emit(data, describe(data));
948
+ return EXIT.OK;
949
+ }
950
+ if (action === 'import') {
951
+ const where = args[1];
952
+ if (!where) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances costs import <file|url> [--source opendevice] [--header "Authorization: token …"]');
953
+ const body = await readCostsInput(where, flags.header);
954
+ const data = await api.importDeclaredCosts(client, body, { source: source || 'opendevice' });
955
+ emit(data, `Imported ${data.upserted} into ${data.source}${data.removed ? `, removed ${data.removed}` : ''}.\n${describe(data)}\nSee it in: coinpay finances recurring`);
956
+ return EXIT.OK;
957
+ }
958
+ if (action === 'clear') {
959
+ if (!source) throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances costs clear --source <name> --yes');
960
+ if (!flags.yes) throw new CliExit(EXIT.INVALID, `This removes every declared cost from ${source}. Re-run with --yes to confirm.`);
961
+ const data = await api.importDeclaredCosts(client, [], { source });
962
+ emit(data, `Removed ${data.removed} declared cost(s) from ${source}.`);
963
+ return EXIT.OK;
964
+ }
965
+ throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances costs [list [--source s]|import <file|url> [--source opendevice] [--header "K: V"]|clear --source s --yes]');
966
+ }
967
+
968
+ case 'goals':
969
+ case 'goal': {
970
+ const action = args[0] || 'list';
971
+ const usage = 'Usage: coinpay finances goals [list|show <id>|create --name "Down payment" --kind house|car|emergency_fund|debt_payoff|custom --target 60000 --deadline 2031-06-30 [--start-amount 5000] [--account <id>,<id>] [--wallet <id>]|contribute <id> <amount> [--date 2026-10-01] [--note "..."]|report [--period daily|weekly|monthly|quarterly]|digest [show|on --cadence monthly [--hour 8]|off|send-now]|sources|archive <id>|delete <id> --yes]';
972
+ const money = (cur, n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD', maximumFractionDigits: 2 }).format(n);
973
+ const STATUS = { achieved: 'achieved', on_track: 'on track', behind: 'BEHIND', overdue: 'PAST DEADLINE' };
974
+ const line = (g) => {
975
+ const s = g.snapshot;
976
+ const pace = s.status === 'achieved' ? '' : ` · ${money(g.currency, s.required.perMonth)}/mo to finish`;
977
+ return ` ${g.id.slice(0, 8)} ${g.name.padEnd(24).slice(0, 24)} ${money(g.currency, s.progress).padStart(12)} / ${money(g.currency, g.targetAmount).padEnd(12)} ${String(s.percent).padStart(5)}% ${STATUS[s.status].padEnd(13)} by ${g.deadline}${pace}`;
978
+ };
979
+ const resolveId = async (prefix) => {
980
+ if (!prefix) throw new CliExit(EXIT.INVALID, usage);
981
+ if (/^[0-9a-f-]{36}$/i.test(prefix)) return prefix;
982
+ const { goals } = await api.listGoals(client, { archived: true });
983
+ const hits = goals.filter((g) => g.id.startsWith(prefix));
984
+ if (hits.length !== 1) throw new CliExit(EXIT.INVALID, hits.length ? `"${prefix}" matches ${hits.length} goals; give more of the id` : `No goal ${prefix}`);
985
+ return hits[0].id;
986
+ };
987
+ if (action === 'list') {
988
+ const data = await api.listGoals(client, { archived: Boolean(flags.archived) });
989
+ emit(data, data.goals.length ? [`${data.goals.length} goal(s)`, ...data.goals.map(line), '', data.disclaimer].join('\n') : 'No goals yet. Create one: coinpay finances goals create --name "Down payment" --kind house --target 60000 --deadline 2031-06-30');
990
+ return EXIT.OK;
991
+ }
992
+ if (action === 'show') {
993
+ const data = await api.getGoal(client, await resolveId(args[1]));
994
+ const g = data.goal;
995
+ const s = g.snapshot;
996
+ const text = [
997
+ `${g.name} (${g.kind}) · ${STATUS[s.status]}`,
998
+ ` ${money(g.currency, s.progress)} of ${money(g.currency, g.targetAmount)} (${s.percent}%) · started ${g.startDate} · deadline ${g.deadline} (${s.daysLeft} days)`,
999
+ ` Expected by now: ${money(g.currency, s.expectedByNow)} · remaining ${money(g.currency, s.remaining)}`,
1000
+ ` To finish on time: ${money(g.currency, s.required.perDay)}/day · ${money(g.currency, s.required.perWeek)}/week · ${money(g.currency, s.required.perMonth)}/month · ${money(g.currency, s.required.perQuarter)}/quarter`,
1001
+ s.recentPerMonth !== null ? ` Last 90 days: ${money(g.currency, s.recentPerMonth)}/month${s.projectedDate ? `, lands ${s.projectedDate}` : ''}` : null,
1002
+ data.contributions.length ? ` Contributions: ${data.contributions.slice(0, 10).map((c) => `${c.occurredOn} ${money(g.currency, c.amount)}`).join(', ')}` : null,
1003
+ ].filter(Boolean).join('\n');
1004
+ emit(data, text);
1005
+ return EXIT.OK;
1006
+ }
1007
+ if (action === 'create' || action === 'add') {
1008
+ const goal = {
1009
+ name: typeof flags.name === 'string' ? flags.name : args.slice(1).join(' '),
1010
+ kind: typeof flags.kind === 'string' ? flags.kind : 'custom',
1011
+ targetAmount: flags.target !== undefined ? String(flags.target) : undefined,
1012
+ deadline: typeof flags.deadline === 'string' ? flags.deadline : undefined,
1013
+ currency: typeof flags.currency === 'string' ? flags.currency : undefined,
1014
+ startingAmount: flags['start-amount'] !== undefined ? String(flags['start-amount']) : undefined,
1015
+ linkedAccountIds: flags.account ? listFlag(flags.account) : undefined,
1016
+ linkedWalletIds: flags.wallet ? listFlag(flags.wallet) : undefined,
1017
+ notes: typeof flags.notes === 'string' ? flags.notes : undefined,
1018
+ };
1019
+ if (!goal.name || !goal.deadline) throw new CliExit(EXIT.INVALID, usage);
1020
+ const g = await api.createGoal(client, goal);
1021
+ emit({ goal: g }, `Created ${g.id}\n${g.snapshot ? line(g) : ''}`);
1022
+ return EXIT.OK;
1023
+ }
1024
+ if (action === 'contribute' || action === 'pay') {
1025
+ const id = await resolveId(args[1]);
1026
+ if (args[2] === undefined) throw new CliExit(EXIT.INVALID, usage);
1027
+ const c = await api.addGoalContribution(client, id, { amount: args[2], occurredOn: typeof flags.date === 'string' ? flags.date : undefined, note: typeof flags.note === 'string' ? flags.note : undefined });
1028
+ emit({ contribution: c }, `Recorded ${c.amount} on ${c.occurredOn}. (Recorded only; nothing was moved.)`);
1029
+ return EXIT.OK;
1030
+ }
1031
+ if (action === 'report') {
1032
+ const period = typeof flags.period === 'string' ? flags.period : args[1] || 'weekly';
1033
+ if (!['daily', 'weekly', 'monthly', 'quarterly'].includes(period)) throw new CliExit(EXIT.INVALID, '--period must be daily, weekly, monthly or quarterly');
1034
+ const data = await api.getGoalsReport(client, { period });
1035
+ if (json) { out(JSON.stringify(data.report, null, 2)); return EXIT.OK; }
1036
+ out(data.text);
1037
+ return EXIT.OK;
1038
+ }
1039
+ if (action === 'sources') {
1040
+ const data = await api.getGoalSources(client);
1041
+ emit(data, [
1042
+ 'Accounts:',
1043
+ ...data.accounts.map((a) => ` ${a.id} ${(a.institution ? `${a.institution} · ` : '') + a.name}${a.liability ? ' (debt)' : ''}${a.balance !== null ? ` ${money(a.currency, a.balance)}` : ''}`),
1044
+ 'Wallets:',
1045
+ ...(data.wallets.length ? data.wallets.map((w) => ` ${w.walletId} ${w.label ?? ''}`) : [' none']),
1046
+ ].join('\n'));
1047
+ return EXIT.OK;
1048
+ }
1049
+ if (action === 'digest') {
1050
+ const sub = args[1] || 'show';
1051
+ const describe = (s) => s
1052
+ ? `Goals report ${s.active ? 'on' : 'off'} · ${s.cadence ?? 'weekly'} at ${String(s.hour).padStart(2, '0')}:00 ${s.timezone} · to ${s.recipients.join(', ')}${s.nextRunAt ? ` · next ${s.nextRunAt}` : ''}`
1053
+ : 'Goals report email is off. Turn it on: coinpay finances goals digest on --cadence monthly';
1054
+ if (sub === 'show') { const s = await api.getGoalsDigest(client); emit({ schedule: s }, describe(s)); return EXIT.OK; }
1055
+ if (sub === 'on') {
1056
+ const s = await api.setGoalsDigest(client, {
1057
+ cadence: typeof flags.cadence === 'string' ? flags.cadence : undefined,
1058
+ hour: flags.hour !== undefined ? Number(flags.hour) : undefined,
1059
+ timezone: typeof flags.timezone === 'string' ? flags.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone,
1060
+ recipients: flags.to ? listFlag(flags.to) : undefined,
1061
+ });
1062
+ emit({ schedule: s }, describe(s));
1063
+ return EXIT.OK;
1064
+ }
1065
+ if (sub === 'off') { const d = await api.deleteGoalsDigest(client); emit(d, 'Goals report email turned off.'); return EXIT.OK; }
1066
+ if (sub === 'send-now') {
1067
+ const d = await api.sendGoalsDigestNow(client);
1068
+ emit(d, `Sent the ${d.period} goals report to ${d.sent.join(', ') || 'nobody'}${d.failed.length ? `; failed: ${d.failed.map((f) => `${f.to} (${f.error})`).join(', ')}` : ''}`);
1069
+ return d.sent.length > 0 ? EXIT.OK : EXIT.FAILURE;
1070
+ }
1071
+ throw new CliExit(EXIT.INVALID, usage);
1072
+ }
1073
+ if (action === 'archive') {
1074
+ const g = await api.updateGoal(client, await resolveId(args[1]), { archived: true });
1075
+ emit({ goal: g }, `Archived ${g.name}.`);
1076
+ return EXIT.OK;
1077
+ }
1078
+ if (action === 'delete') {
1079
+ const id = await resolveId(args[1]);
1080
+ if (!flags.yes) throw new CliExit(EXIT.INVALID, 'This deletes the goal and its recorded contributions. Re-run with --yes to confirm.');
1081
+ const d = await api.deleteGoal(client, id);
1082
+ emit(d, 'Goal deleted.');
1083
+ return EXIT.OK;
1084
+ }
1085
+ throw new CliExit(EXIT.INVALID, usage);
1086
+ }
1087
+
903
1088
  case 'due-day': {
904
1089
  const id = args[0];
905
1090
  const value = args[1];
@@ -991,4 +1176,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
991
1176
  }
992
1177
  }
993
1178
 
994
- export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'pnl', 'profit-loss', 'payloads', 'digest', 'due', 'due-soon', 'recurring', 'due-day', 'daily'];
1179
+ export const EXTENDED_SUBCOMMANDS = ['connect', 'disconnect', 'consent', 'backfill', 'jobs', 'coverage', 'report', 'reports', 'statements', 'books', 'pnl', 'profit-loss', 'payloads', 'digest', 'due', 'due-soon', 'recurring', 'due-day', 'daily', 'costs', 'goals', 'goal'];
@@ -420,14 +420,43 @@ export interface DueSoon {
420
420
  missed: Array<{ payee: string; amount: number; expectedOn: string; lastSeen: string; accountName: string }>;
421
421
  totals: { due: number; overdue: number; unpriced: number };
422
422
  recurring: {
423
- charges: Array<{ payee: string; amount: number; cadence: string; occurrences: number; lastSeen: string; nextExpected: string; monthlyEquivalent: number; scope: 'business' | 'personal'; isDebtService: boolean; accountId: string; accountName: string }>;
423
+ charges: Array<{ key: string; payee: string; amount: number; cadence: string; occurrences: number; lastSeen: string; nextExpected: string; monthlyEquivalent: number; scope: 'business' | 'personal'; isDebtService: boolean; accountId: string; accountName: string; origin?: 'observed' | 'matched' | 'declared'; declared?: DeclaredCostRef }>;
424
424
  monthlyTotal: number;
425
425
  monthlyDebtService: number;
426
+ /** The declared part of monthlyTotal that no feed shows. */
427
+ monthlyDeclared?: number;
428
+ declaredSkipped?: number;
426
429
  };
427
430
  currency: string | null;
428
431
  }
429
432
 
430
433
  export function getDueSoon(client: CoinPayClient, opts?: { days?: number; timezone?: string }): Promise<DueSoon>;
434
+
435
+ export interface DeclaredCostRef {
436
+ source: string;
437
+ key: string;
438
+ payee: string;
439
+ amount: number;
440
+ cadence: 'monthly' | 'yearly';
441
+ monthlyEquivalent: number;
442
+ category: string | null;
443
+ paidWith: string | null;
444
+ meta: Record<string, unknown>;
445
+ }
446
+
447
+ export interface DeclaredCost extends DeclaredCostRef {
448
+ currency: string;
449
+ scope: 'business' | 'personal';
450
+ nextExpected: string | null;
451
+ updatedAt?: string | null;
452
+ }
453
+
454
+ export function listDeclaredCosts(client: CoinPayClient, opts?: { source?: string }): Promise<{ costs: DeclaredCost[]; monthly: Record<string, number> }>;
455
+ export function importDeclaredCosts(
456
+ client: CoinPayClient,
457
+ body: unknown,
458
+ opts?: { source?: string },
459
+ ): Promise<{ source: string; removed: number; upserted: number; costs: DeclaredCost[]; monthly: Record<string, number> }>;
431
460
  export function getDailySummary(client: CoinPayClient): Promise<WeeklyDigest | null>;
432
461
  export function setDailySummary(
433
462
  client: CoinPayClient,
@@ -436,3 +465,59 @@ export function setDailySummary(
436
465
  export function deleteDailySummary(client: CoinPayClient): Promise<{ success: boolean }>;
437
466
  export function sendDailySummaryNow(client: CoinPayClient): Promise<{ sent: string[]; failed: Array<{ to: string; error: string }>; due: number; overdue: number }>;
438
467
  export function setAccountDueDay(client: CoinPayClient, accountId: string, opts: { day: number | null; amount?: number | string | null }): Promise<Record<string, unknown>>;
468
+
469
+ export type GoalKind = 'house' | 'car' | 'emergency_fund' | 'debt_payoff' | 'custom';
470
+ export type GoalPeriod = 'daily' | 'weekly' | 'monthly' | 'quarterly';
471
+ export type GoalStatus = 'achieved' | 'on_track' | 'behind' | 'overdue';
472
+ export interface GoalSnapshot {
473
+ progress: number;
474
+ remaining: number;
475
+ percent: number;
476
+ expectedByNow: number;
477
+ status: GoalStatus;
478
+ daysLeft: number;
479
+ required: { perDay: number; perWeek: number; perMonth: number; perQuarter: number };
480
+ recentPerMonth: number | null;
481
+ projectedDate: string | null;
482
+ exact: boolean;
483
+ }
484
+ export interface FinanceGoal {
485
+ id: string;
486
+ name: string;
487
+ kind: GoalKind;
488
+ targetAmount: number;
489
+ currency: string;
490
+ startDate: string;
491
+ deadline: string;
492
+ startingAmount: number | null;
493
+ startProgress: number;
494
+ linkedAccountIds: string[];
495
+ linkedWalletIds: string[];
496
+ notes: string | null;
497
+ direction: 'save' | 'payoff';
498
+ snapshot: GoalSnapshot;
499
+ }
500
+ export interface GoalInput {
501
+ name: string;
502
+ kind: GoalKind;
503
+ targetAmount?: number | string;
504
+ deadline: string;
505
+ currency?: string;
506
+ startDate?: string;
507
+ startingAmount?: number | string | null;
508
+ linkedAccountIds?: string[];
509
+ linkedWalletIds?: string[];
510
+ notes?: string | null;
511
+ }
512
+ export function listGoals(client: CoinPayClient, opts?: { archived?: boolean }): Promise<{ goals: FinanceGoal[]; timezone: string; disclaimer: string }>;
513
+ export function getGoal(client: CoinPayClient, id: string): Promise<{ goal: FinanceGoal; contributions: Array<{ id: string; amount: number; occurredOn: string; note: string | null }> }>;
514
+ export function createGoal(client: CoinPayClient, goal: GoalInput): Promise<FinanceGoal>;
515
+ export function updateGoal(client: CoinPayClient, id: string, patch: Partial<GoalInput> & { archived?: boolean; startProgress?: number }): Promise<FinanceGoal>;
516
+ export function deleteGoal(client: CoinPayClient, id: string): Promise<{ success: boolean }>;
517
+ export function addGoalContribution(client: CoinPayClient, id: string, c: { amount: number | string; occurredOn?: string; note?: string }): Promise<{ id: string; amount: number; occurredOn: string; note: string | null }>;
518
+ export function getGoalSources(client: CoinPayClient): Promise<{ accounts: Array<{ id: string; name: string; institution: string | null; liability: boolean; currency: string; balance: number | null }>; wallets: Array<{ walletId: string; label: string | null }> }>;
519
+ export function getGoalsReport(client: CoinPayClient, opts?: { period?: GoalPeriod }): Promise<{ report: Record<string, unknown>; text: string }>;
520
+ export function getGoalsDigest(client: CoinPayClient): Promise<(WeeklyDigest & { cadence?: GoalPeriod }) | null>;
521
+ export function setGoalsDigest(client: CoinPayClient, opts?: { cadence?: GoalPeriod; hour?: number; timezone?: string; recipients?: string[]; active?: boolean }): Promise<WeeklyDigest & { cadence?: GoalPeriod }>;
522
+ export function deleteGoalsDigest(client: CoinPayClient): Promise<{ success: boolean }>;
523
+ export function sendGoalsDigestNow(client: CoinPayClient): Promise<{ sent: string[]; failed: Array<{ to: string; error: string }>; goals: number; period: GoalPeriod }>;
@@ -466,6 +466,27 @@ export async function getDueSoon(client, { days, timezone } = {}) {
466
466
  return data.dueSoon;
467
467
  }
468
468
 
469
+ /**
470
+ * Declared recurring costs (servers paid by PayPal or crypto, which no bank
471
+ * feed shows). `source` defaults to every source.
472
+ */
473
+ export async function listDeclaredCosts(client, { source } = {}) {
474
+ const qs = source ? `?source=${encodeURIComponent(source)}` : '';
475
+ return call(client, `/finances/declared-costs${qs}`);
476
+ }
477
+
478
+ /**
479
+ * Replace one source's declared costs. `body` is an OpenDevice inventory (an
480
+ * object with `devices`), an array of `toCoinPayCosts` rows from
481
+ * @profullstack/fleet, or `{ costs: [...] }`. An empty array clears it.
482
+ */
483
+ export async function importDeclaredCosts(client, body, { source = 'opendevice' } = {}) {
484
+ return call(client, `/finances/declared-costs?source=${encodeURIComponent(source)}`, {
485
+ method: 'PUT',
486
+ body: JSON.stringify(body),
487
+ });
488
+ }
489
+
469
490
  export async function getDailySummary(client) {
470
491
  const data = await call(client, '/finances/email-schedules?kind=daily_summary');
471
492
  return data.schedule;
@@ -509,3 +530,72 @@ export async function setAccountDueDay(client, accountId, { day, amount } = {})
509
530
  const data = await call(client, `/finances/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body: JSON.stringify(body) });
510
531
  return data.account;
511
532
  }
533
+
534
+ /**
535
+ * Finance goals: saving toward something or paying something off, by a
536
+ * deadline. Progress follows linked finance accounts and web wallets, or
537
+ * contributions recorded by hand. Records only; nothing moves money.
538
+ */
539
+ export async function listGoals(client, { archived = false } = {}) {
540
+ return call(client, `/finances/goals${archived ? '?archived=1' : ''}`);
541
+ }
542
+
543
+ export async function getGoal(client, id) {
544
+ return call(client, `/finances/goals/${encodeURIComponent(id)}`);
545
+ }
546
+
547
+ /** `{ name, kind, targetAmount, deadline, currency?, startDate?, startingAmount?, linkedAccountIds?, linkedWalletIds?, notes? }` */
548
+ export async function createGoal(client, goal) {
549
+ const data = await call(client, '/finances/goals', { method: 'POST', body: JSON.stringify(goal) });
550
+ return data.goal;
551
+ }
552
+
553
+ export async function updateGoal(client, id, patch) {
554
+ const data = await call(client, `/finances/goals/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch) });
555
+ return data.goal;
556
+ }
557
+
558
+ export async function deleteGoal(client, id) {
559
+ return call(client, `/finances/goals/${encodeURIComponent(id)}`, { method: 'DELETE' });
560
+ }
561
+
562
+ /** Record an amount toward a goal (negative = withdrawal). `occurredOn` defaults to today. */
563
+ export async function addGoalContribution(client, id, { amount, occurredOn, note } = {}) {
564
+ const data = await call(client, `/finances/goals/${encodeURIComponent(id)}/contributions`, {
565
+ method: 'POST',
566
+ body: JSON.stringify({ amount, occurredOn, note }),
567
+ });
568
+ return data.contribution;
569
+ }
570
+
571
+ /** What a goal can be linked to: finance accounts and CoinPay web wallets. */
572
+ export async function getGoalSources(client) {
573
+ return call(client, '/finances/goals/sources');
574
+ }
575
+
576
+ /** The daily, weekly, monthly or quarterly goals report: `{ report, text }`. */
577
+ export async function getGoalsReport(client, { period = 'weekly' } = {}) {
578
+ return call(client, `/finances/goals/report?period=${encodeURIComponent(period)}`);
579
+ }
580
+
581
+ export async function getGoalsDigest(client) {
582
+ const data = await call(client, '/finances/email-schedules?kind=goals_digest');
583
+ return data.schedule;
584
+ }
585
+
586
+ /** Turn the goals report email on at a cadence (daily, weekly, monthly, quarterly). */
587
+ export async function setGoalsDigest(client, { cadence, hour, timezone, recipients, active = true } = {}) {
588
+ const data = await call(client, '/finances/email-schedules', {
589
+ method: 'POST',
590
+ body: JSON.stringify({ kind: 'goals_digest', cadence, hour, timezone, recipients, active }),
591
+ });
592
+ return data.schedule;
593
+ }
594
+
595
+ export async function deleteGoalsDigest(client) {
596
+ return call(client, '/finances/email-schedules?kind=goals_digest', { method: 'DELETE' });
597
+ }
598
+
599
+ export async function sendGoalsDigestNow(client) {
600
+ return call(client, '/finances/email-schedules/send-now?kind=goals_digest', { method: 'POST', body: '{}' });
601
+ }
package/src/mcp.js CHANGED
@@ -55,6 +55,101 @@ export const TOOLS = [
55
55
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
56
56
  handler: async (_args, { client }) => text((await reports.getDueSoon(client, { days: 0 })).recurring),
57
57
  },
58
+ {
59
+ name: 'finance_declared_costs',
60
+ description: 'Declared recurring costs: bills no bank feed shows (servers paid by PayPal or crypto), stated by the owner, e.g. from an OpenDevice inventory. They are merged into finance_recurring (origin "declared", or "matched" when a feed charge is the same bill).',
61
+ inputSchema: { type: 'object', properties: { source: { type: 'string', description: 'Only this source, e.g. opendevice' } }, additionalProperties: false },
62
+ handler: async (args, { client }) => text(await reports.listDeclaredCosts(client, { source: args.source })),
63
+ },
64
+ {
65
+ name: 'finance_import_costs',
66
+ description: 'Replace one source\'s declared recurring costs. Pass an OpenDevice inventory (an object with "devices"; the server converts it) or an array of @profullstack/fleet toCoinPayCosts rows. An empty array clears the source.',
67
+ inputSchema: {
68
+ type: 'object',
69
+ properties: {
70
+ source: { type: 'string', description: 'Source name (default opendevice)' },
71
+ inventory: { type: 'object', description: 'An OpenDevice inventory' },
72
+ costs: { type: 'array', items: { type: 'object' }, description: 'toCoinPayCosts rows' },
73
+ },
74
+ additionalProperties: false,
75
+ },
76
+ handler: async (args, { client }) => {
77
+ const body = args.inventory ?? args.costs;
78
+ if (body === undefined) throw new Error('Pass inventory or costs');
79
+ return text(await reports.importDeclaredCosts(client, body, { source: args.source || 'opendevice' }));
80
+ },
81
+ },
82
+ {
83
+ name: 'finance_goals',
84
+ description: 'Savings and payoff goals (buy a house or car, emergency fund, pay off debt): each with progress, percent, on track/behind/achieved/past deadline, and what it takes per day/week/month/quarter to finish by the deadline. Not financial advice.',
85
+ inputSchema: { type: 'object', properties: { archived: { type: 'boolean', description: 'Include archived goals' } }, additionalProperties: false },
86
+ handler: async (args, { client }) => text(await reports.listGoals(client, { archived: args.archived === true })),
87
+ },
88
+ {
89
+ name: 'finance_goal_create',
90
+ description: 'Create a goal. kind: house, car, emergency_fund, debt_payoff or custom. Link finance account ids (finance_accounts; debt accounts for a payoff goal) or web wallet ids so progress follows real balances; otherwise progress is startingAmount plus recorded contributions. A payoff goal linked to debt accounts with no startingAmount starts at what they owe now.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ name: { type: 'string' },
95
+ kind: { type: 'string', enum: ['house', 'car', 'emergency_fund', 'debt_payoff', 'custom'] },
96
+ targetAmount: { type: ['number', 'string'] },
97
+ deadline: { type: 'string', description: 'YYYY-MM-DD' },
98
+ currency: { type: 'string', description: 'Default USD' },
99
+ startDate: { type: 'string', description: 'YYYY-MM-DD, default today' },
100
+ startingAmount: { type: ['number', 'string'], description: 'Already saved (save goal) or the debt at the start (payoff goal)' },
101
+ linkedAccountIds: { type: 'array', items: { type: 'string' } },
102
+ linkedWalletIds: { type: 'array', items: { type: 'string' } },
103
+ notes: { type: 'string' },
104
+ },
105
+ required: ['name', 'kind', 'deadline'],
106
+ additionalProperties: false,
107
+ },
108
+ handler: async (args, { client }) => text({ goal: await reports.createGoal(client, args) }),
109
+ },
110
+ {
111
+ name: 'finance_goal_contribute',
112
+ description: 'Record an amount put toward a goal, or a payment on a payoff goal (negative = withdrawal). Records only; moves no money.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ goalId: { type: 'string' },
117
+ amount: { type: ['number', 'string'] },
118
+ occurredOn: { type: 'string', description: 'YYYY-MM-DD, default today' },
119
+ note: { type: 'string' },
120
+ },
121
+ required: ['goalId', 'amount'],
122
+ additionalProperties: false,
123
+ },
124
+ handler: async (args, { client }) => text({ contribution: await reports.addGoalContribution(client, args.goalId, { amount: args.amount, occurredOn: args.occurredOn, note: args.note }) }),
125
+ },
126
+ {
127
+ name: 'finance_goals_report',
128
+ description: 'The goals progress report for the trailing day, week, month or quarter: each goal\'s change in the period, what the period needed to stay on schedule, pace (ahead/on pace/short), and overall status.',
129
+ inputSchema: { type: 'object', properties: { period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'quarterly'] } }, additionalProperties: false },
130
+ handler: async (args, { client }) => text((await reports.getGoalsReport(client, { period: args.period || 'weekly' })).text),
131
+ },
132
+ {
133
+ name: 'finance_goals_digest',
134
+ description: 'The opt-in goals report email: show it, turn it on at a cadence (daily, weekly, monthly, quarterly; optional hour 0-23 and recipients), turn it off, or send one now.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: {
138
+ action: { type: 'string', enum: ['show', 'on', 'off', 'send_now'] },
139
+ cadence: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'quarterly'] },
140
+ hour: { type: 'integer', minimum: 0, maximum: 23 },
141
+ to: { type: 'array', items: { type: 'string' }, maxItems: 5 },
142
+ },
143
+ required: ['action'],
144
+ additionalProperties: false,
145
+ },
146
+ handler: async (args, { client }) => {
147
+ if (args.action === 'on') return text({ schedule: await reports.setGoalsDigest(client, { cadence: args.cadence, hour: args.hour, recipients: args.to }) });
148
+ if (args.action === 'off') return text(await reports.deleteGoalsDigest(client));
149
+ if (args.action === 'send_now') return text(await reports.sendGoalsDigestNow(client));
150
+ return text({ schedule: await reports.getGoalsDigest(client) });
151
+ },
152
+ },
58
153
  {
59
154
  name: 'finance_summary',
60
155
  description: 'Balance sheet (assets, debt, net by currency), cashflow for the window, top spending categories, and the debt-and-income position.',
@@ -427,7 +522,7 @@ export async function handleMessage(message, ctx) {
427
522
  capabilities: { tools: {} },
428
523
  serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
429
524
  instructions:
430
- '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.',
525
+ 'CoinPay finances. finance_due_soon answers "what is due" across every account; finance_recurring lists subscriptions and bills (declared costs such as servers paid by PayPal included, see finance_declared_costs); 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.',
431
526
  });
432
527
  case 'notifications/initialized':
433
528
  case 'notifications/cancelled':