@profullstack/coinpay 0.24.1 → 0.26.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 +23 -0
- package/bin/coinpay.js +13 -7
- package/package.json +11 -11
- package/src/client.d.ts +60 -0
- package/src/client.js +83 -0
- package/src/finances-commands.js +70 -4
- package/src/finances-reports.d.ts +30 -1
- package/src/finances-reports.js +23 -2
- package/src/mcp.js +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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.26.0] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Declared recurring costs: bills no bank or card feed shows (servers paid by PayPal, crypto or an
|
|
12
|
+
unlinked card). `coinpay finances costs import <file|url> [--source opendevice] [--header "K: V"]`
|
|
13
|
+
takes an OpenDevice inventory (converted server-side with `@profullstack/fleet`) or `toCoinPayCosts`
|
|
14
|
+
rows and replaces that source's set; `coinpay finances costs list [--json]`, `costs clear --source s --yes`.
|
|
15
|
+
SDK `listDeclaredCosts(client, { source })`, `importDeclaredCosts(client, body, { source })`; MCP
|
|
16
|
+
`finance_declared_costs`, `finance_import_costs`.
|
|
17
|
+
- `coinpay finances recurring` (and `--json`) now includes declared costs: each charge has `origin`
|
|
18
|
+
`observed`, `declared` (not in any feed) or `matched` (seen in the feed and declared: payee, amount
|
|
19
|
+
within 10% and interval agree; counted once, as observed, with the declaration in `declared`).
|
|
20
|
+
`monthlyTotal` includes declared costs; `monthlyDeclared` is that part.
|
|
21
|
+
|
|
22
|
+
## [0.25.0] - 2026-10-21
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `createCheckoutSession({ amount, customerEmail, accountId, property, metadata, successUrl, cancelUrl, expiresInMinutes, allowedMethods })`
|
|
26
|
+
and `getCheckoutSession(id)`: one hosted checkout URL where the buyer picks any coin the business can
|
|
27
|
+
receive, or PayPal. Webhooks for the resulting payment carry `checkout_session_id`, `customer_email`,
|
|
28
|
+
`account_id`, `property` and `metadata`.
|
|
29
|
+
- `createPayment({ customerEmail, accountId, property })`, stored in the payment's metadata.
|
|
30
|
+
|
|
8
31
|
## [0.23.0] - 2026-10-21
|
|
9
32
|
|
|
10
33
|
### 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: {} };
|
|
@@ -712,7 +712,8 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
712
712
|
--with-documents: a ZIP with the period's tax documents, up to 8 MiB)
|
|
713
713
|
pnl Profit and loss, personal and business as separate files
|
|
714
714
|
(--scope personal|business, --months 3 (default: last 3
|
|
715
|
-
complete months) or --from/--to, --name "Holder", --format pdf|html|csv|json
|
|
715
|
+
complete months) or --from/--to, --name "Holder", --format pdf|html|csv|json,
|
|
716
|
+
--newest-first puts the latest month first)
|
|
716
717
|
payloads [list|download <id>] Raw provider responses, exactly as received
|
|
717
718
|
reports send <id> Email a report (--to a@x,b@y, --format pdf,csv, --message, --no-attach)
|
|
718
719
|
books send Email the CPA pack (--to, --period, --scope, --format,
|
|
@@ -721,6 +722,10 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
721
722
|
digest [show|set|off|send-now] Weekly digest email (--days mon,fri --hour 8 --to …)
|
|
722
723
|
due [--days 7] What is due and overdue across every account (--days up to 60)
|
|
723
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)
|
|
724
729
|
due-day <id> <1-31|clear> A card/loan's statement due day (--amount 810.46, or balance)
|
|
725
730
|
daily [show|on|off|send-now] Daily summary email: spend vs target, top cuts, due soon, position (--hour 7 --to …, --target 100)
|
|
726
731
|
|
|
@@ -4999,10 +5004,10 @@ function printPosition(position, fmt, { heading = false, detail = false } = {})
|
|
|
4999
5004
|
{ title: 'Amount', align: 'right', render: (c) => money(c.amount, cur) },
|
|
5000
5005
|
{ title: 'Every', align: 'left', render: (c) => c.cadence },
|
|
5001
5006
|
{ title: 'Seen', align: 'right', render: (c) => String(c.occurrences) },
|
|
5002
|
-
{ title: 'Last', align: 'left', render: (c) => c.lastSeen.slice(0, 10) },
|
|
5003
|
-
{ title: 'Next', align: 'left', render: (c) => c.nextExpected.slice(0, 10) },
|
|
5007
|
+
{ title: 'Last', align: 'left', render: (c) => (c.origin === 'declared' ? '-' : c.lastSeen.slice(0, 10)) },
|
|
5008
|
+
{ title: 'Next', align: 'left', render: (c) => (c.nextExpected || '-').slice(0, 10) },
|
|
5004
5009
|
{ title: 'Per month', align: 'right', render: (c) => money(c.monthlyEquivalent, cur) },
|
|
5005
|
-
{ title: 'Kind', align: 'left', render: (c) => (c.isDebtService ? 'debt' : 'bill') },
|
|
5010
|
+
{ title: 'Kind', align: 'left', render: (c) => (c.isDebtService ? 'debt' : c.origin === 'declared' ? 'declared' : c.origin === 'matched' ? 'bill (declared)' : 'bill') },
|
|
5006
5011
|
]);
|
|
5007
5012
|
}
|
|
5008
5013
|
console.log('');
|
|
@@ -5252,7 +5257,8 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
5252
5257
|
case 'due-soon':
|
|
5253
5258
|
case 'recurring':
|
|
5254
5259
|
case 'due-day':
|
|
5255
|
-
case 'daily':
|
|
5260
|
+
case 'daily':
|
|
5261
|
+
case 'costs': {
|
|
5256
5262
|
const { client } = financesClient();
|
|
5257
5263
|
const { runFinancesCommand } = await import('../src/finances-commands.js');
|
|
5258
5264
|
const code = await runFinancesCommand(subcommand, args, flags, {
|
|
@@ -5266,7 +5272,7 @@ async function handleFinances(subcommand, args, flags) {
|
|
|
5266
5272
|
|
|
5267
5273
|
default:
|
|
5268
5274
|
print.error(`Unknown finances command: ${subcommand}`);
|
|
5269
|
-
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]');
|
|
5275
|
+
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]');
|
|
5270
5276
|
process.exit(1);
|
|
5271
5277
|
}
|
|
5272
5278
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CoinPay SDK & CLI
|
|
3
|
+
"version": "0.26.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.
|
|
139
|
-
"prettier": "^3.
|
|
140
|
-
"vitest": "^4.1.
|
|
138
|
+
"eslint": "^8.57.1",
|
|
139
|
+
"prettier": "^3.9.10",
|
|
140
|
+
"vitest": "^4.1.12"
|
|
141
141
|
},
|
|
142
142
|
"dependencies": {
|
|
143
|
-
"@inquirer/prompts": "^8.
|
|
144
|
-
"@noble/curves": "^1.
|
|
145
|
-
"@noble/hashes": "^1.
|
|
146
|
-
"@profullstack/hqtui": "^0.
|
|
147
|
-
"@scure/bip32": "^1.
|
|
148
|
-
"@scure/bip39": "^1.
|
|
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
|
}
|
package/src/client.d.ts
CHANGED
|
@@ -31,6 +31,57 @@ export interface PaymentParams {
|
|
|
31
31
|
successUrl?: string;
|
|
32
32
|
/** Where "Back to merchant" goes from an expired or abandoned checkout */
|
|
33
33
|
cancelUrl?: string;
|
|
34
|
+
/** Email of the person paying; stored in metadata and sent on webhooks */
|
|
35
|
+
customerEmail?: string;
|
|
36
|
+
/** Your site's user id or handle for the payer */
|
|
37
|
+
accountId?: string;
|
|
38
|
+
/** Your site hostname */
|
|
39
|
+
property?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parameters for creating a hosted checkout session */
|
|
43
|
+
export interface CheckoutSessionParams {
|
|
44
|
+
/** Amount in USD */
|
|
45
|
+
amount: number;
|
|
46
|
+
/** Only `'USD'` */
|
|
47
|
+
currency?: 'USD';
|
|
48
|
+
description?: string;
|
|
49
|
+
/** Email of the person paying (required) */
|
|
50
|
+
customerEmail: string;
|
|
51
|
+
/** Your site's user id or handle for the payer (required) */
|
|
52
|
+
accountId: string;
|
|
53
|
+
/** Your site hostname (defaults to the business name) */
|
|
54
|
+
property?: string;
|
|
55
|
+
metadata?: Record<string, unknown>;
|
|
56
|
+
/** `{CHECKOUT_SESSION_ID}` and `{PAYMENT_ID}` are filled in */
|
|
57
|
+
successUrl?: string;
|
|
58
|
+
cancelUrl?: string;
|
|
59
|
+
/** 15 to 120, default 60 */
|
|
60
|
+
expiresInMinutes?: number;
|
|
61
|
+
/** Coin codes (e.g. `'USDC_POL'`), `'CRYPTO'` or `'PAYPAL'`; default all */
|
|
62
|
+
allowedMethods?: string[];
|
|
63
|
+
/** Only with a merchant session token; a business API key implies it */
|
|
64
|
+
businessId?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Response from `createCheckoutSession` */
|
|
68
|
+
export interface CheckoutSessionResponse {
|
|
69
|
+
success: boolean;
|
|
70
|
+
id: string;
|
|
71
|
+
url: string;
|
|
72
|
+
status: 'open' | 'paid' | 'expired';
|
|
73
|
+
amount: number;
|
|
74
|
+
currency: 'USD';
|
|
75
|
+
description: string | null;
|
|
76
|
+
customer_email: string;
|
|
77
|
+
account_id: string;
|
|
78
|
+
property: string;
|
|
79
|
+
metadata: Record<string, unknown>;
|
|
80
|
+
success_url: string | null;
|
|
81
|
+
cancel_url: string | null;
|
|
82
|
+
expires_at: string;
|
|
83
|
+
methods: string[];
|
|
84
|
+
[key: string]: unknown;
|
|
34
85
|
}
|
|
35
86
|
|
|
36
87
|
/** Parameters for listing payments */
|
|
@@ -200,6 +251,15 @@ export class CoinPayClient {
|
|
|
200
251
|
*/
|
|
201
252
|
createPayment(params: PaymentParams): Promise<CreatePaymentResponse>;
|
|
202
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Create a hosted checkout session; redirect the buyer to `url`. The buyer
|
|
256
|
+
* picks any coin the business can receive, or PayPal.
|
|
257
|
+
*/
|
|
258
|
+
createCheckoutSession(params: CheckoutSessionParams): Promise<CheckoutSessionResponse>;
|
|
259
|
+
|
|
260
|
+
/** Get a checkout session (`cs_...`). */
|
|
261
|
+
getCheckoutSession(sessionId: string): Promise<Record<string, unknown>>;
|
|
262
|
+
|
|
203
263
|
/**
|
|
204
264
|
* Get payment details by ID.
|
|
205
265
|
*
|
package/src/client.js
CHANGED
|
@@ -255,6 +255,9 @@ export class CoinPayClient {
|
|
|
255
255
|
metadata,
|
|
256
256
|
successUrl,
|
|
257
257
|
cancelUrl,
|
|
258
|
+
customerEmail,
|
|
259
|
+
accountId,
|
|
260
|
+
property,
|
|
258
261
|
}) {
|
|
259
262
|
// Map to API field names (snake_case)
|
|
260
263
|
return this.request('/payments/create', {
|
|
@@ -268,10 +271,90 @@ export class CoinPayClient {
|
|
|
268
271
|
metadata,
|
|
269
272
|
success_url: successUrl,
|
|
270
273
|
cancel_url: cancelUrl,
|
|
274
|
+
customer_email: customerEmail,
|
|
275
|
+
account_id: accountId,
|
|
276
|
+
property,
|
|
271
277
|
}),
|
|
272
278
|
});
|
|
273
279
|
}
|
|
274
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Create a hosted checkout session.
|
|
283
|
+
*
|
|
284
|
+
* Redirect the buyer to the returned `url`. There they choose any coin your
|
|
285
|
+
* business has a wallet for (stablecoins first) or PayPal when connected.
|
|
286
|
+
* The payment and every webhook for it carry `checkout_session_id`,
|
|
287
|
+
* `customer_email`, `account_id`, `property` and your `metadata`.
|
|
288
|
+
*
|
|
289
|
+
* @param {Object} params
|
|
290
|
+
* @param {number} params.amount - Amount in USD
|
|
291
|
+
* @param {string} params.customerEmail - Email of the person paying (required)
|
|
292
|
+
* @param {string} params.accountId - Your site's user id or handle for the payer (required)
|
|
293
|
+
* @param {string} [params.property] - Your site hostname (defaults to the business name)
|
|
294
|
+
* @param {string} [params.description]
|
|
295
|
+
* @param {Object} [params.metadata]
|
|
296
|
+
* @param {string} [params.successUrl] - `{CHECKOUT_SESSION_ID}` and `{PAYMENT_ID}` are filled in
|
|
297
|
+
* @param {string} [params.cancelUrl]
|
|
298
|
+
* @param {number} [params.expiresInMinutes=60] - 15 to 120
|
|
299
|
+
* @param {string[]} [params.allowedMethods] - coin codes, 'CRYPTO' or 'PAYPAL' (default: all)
|
|
300
|
+
* @returns {Promise<{success: boolean, id: string, url: string, status: string, expires_at: string, methods: string[]}>}
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* const { id, url } = await client.createCheckoutSession({
|
|
304
|
+
* amount: 9.99,
|
|
305
|
+
* description: 'Pro plan',
|
|
306
|
+
* customerEmail: 'buyer@example.com',
|
|
307
|
+
* accountId: 'user_123',
|
|
308
|
+
* property: 'example.com',
|
|
309
|
+
* successUrl: 'https://example.com/thanks?session={CHECKOUT_SESSION_ID}',
|
|
310
|
+
* cancelUrl: 'https://example.com/pricing',
|
|
311
|
+
* });
|
|
312
|
+
* // res.redirect(url)
|
|
313
|
+
*/
|
|
314
|
+
async createCheckoutSession({
|
|
315
|
+
amount,
|
|
316
|
+
currency = 'USD',
|
|
317
|
+
description,
|
|
318
|
+
customerEmail,
|
|
319
|
+
accountId,
|
|
320
|
+
property,
|
|
321
|
+
metadata,
|
|
322
|
+
successUrl,
|
|
323
|
+
cancelUrl,
|
|
324
|
+
expiresInMinutes,
|
|
325
|
+
allowedMethods,
|
|
326
|
+
businessId,
|
|
327
|
+
}) {
|
|
328
|
+
return this.request('/checkout/sessions', {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
body: JSON.stringify({
|
|
331
|
+
amount,
|
|
332
|
+
currency,
|
|
333
|
+
description,
|
|
334
|
+
customer_email: customerEmail,
|
|
335
|
+
account_id: accountId,
|
|
336
|
+
property,
|
|
337
|
+
metadata,
|
|
338
|
+
success_url: successUrl,
|
|
339
|
+
cancel_url: cancelUrl,
|
|
340
|
+
expires_in_minutes: expiresInMinutes,
|
|
341
|
+
allowed_methods: allowedMethods,
|
|
342
|
+
business_id: businessId,
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get a checkout session. With your business API key the response includes
|
|
349
|
+
* the customer fields, metadata and the underlying payment ids.
|
|
350
|
+
*
|
|
351
|
+
* @param {string} sessionId - `cs_...`
|
|
352
|
+
* @returns {Promise<Object>} `{ success, session }`
|
|
353
|
+
*/
|
|
354
|
+
async getCheckoutSession(sessionId) {
|
|
355
|
+
return this.request(`/checkout/sessions/${encodeURIComponent(sessionId)}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
275
358
|
/**
|
|
276
359
|
* Get payment by ID
|
|
277
360
|
*
|
package/src/finances-commands.js
CHANGED
|
@@ -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;
|
|
@@ -816,9 +847,10 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
816
847
|
const to = typeof flags.to === 'string' ? flags.to : undefined;
|
|
817
848
|
const timezone = typeof flags.timezone === 'string' ? flags.timezone : undefined;
|
|
818
849
|
const name = typeof flags.name === 'string' ? flags.name : undefined;
|
|
850
|
+
const order = flags['newest-first'] === true ? 'newest' : typeof flags.order === 'string' ? flags.order : undefined;
|
|
819
851
|
const written = [];
|
|
820
852
|
for (const scope of wanted) {
|
|
821
|
-
const file = await api.exportPnl(client, { scope, months, period, from, to, timezone, name, format });
|
|
853
|
+
const file = await api.exportPnl(client, { scope, months, period, from, to, timezone, name, format, order });
|
|
822
854
|
const output = typeof flags.output === 'string' && wanted.length === 1 ? flags.output : file.filename || `pnl-${scope}.${format}`;
|
|
823
855
|
written.push({ scope, output: writeDownload(output, Buffer.from(file.bytes), { overwrite: flags.overwrite === true }), bytes: file.bytes.length });
|
|
824
856
|
}
|
|
@@ -880,10 +912,12 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
880
912
|
const money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n);
|
|
881
913
|
if (subcommand === 'recurring') {
|
|
882
914
|
if (json) { out(JSON.stringify(d.recurring, null, 2)); return EXIT.OK; }
|
|
883
|
-
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'));
|
|
884
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)`);
|
|
885
918
|
for (const c of rows) {
|
|
886
|
-
|
|
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}`);
|
|
887
921
|
}
|
|
888
922
|
return EXIT.OK;
|
|
889
923
|
}
|
|
@@ -899,6 +933,38 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
899
933
|
return EXIT.OK;
|
|
900
934
|
}
|
|
901
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
|
+
|
|
902
968
|
case 'due-day': {
|
|
903
969
|
const id = args[0];
|
|
904
970
|
const value = args[1];
|
|
@@ -990,4 +1056,4 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
990
1056
|
}
|
|
991
1057
|
}
|
|
992
1058
|
|
|
993
|
-
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'];
|
|
1059
|
+
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'];
|
|
@@ -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,
|
package/src/finances-reports.js
CHANGED
|
@@ -403,8 +403,8 @@ export async function exportBooks(client, { period, from, to, timezone, scope, f
|
|
|
403
403
|
* Profit and loss statement for one scope. With no period it covers the
|
|
404
404
|
* last `months` complete calendar months (server default 3).
|
|
405
405
|
*/
|
|
406
|
-
export async function exportPnl(client, { scope, months, period, from, to, timezone, name, format = 'pdf' } = {}) {
|
|
407
|
-
return client.requestBinary(`/finances/pnl${query({ scope, months, period, from, to, timezone, name, format })}`);
|
|
406
|
+
export async function exportPnl(client, { scope, months, period, from, to, timezone, name, format = 'pdf', order } = {}) {
|
|
407
|
+
return client.requestBinary(`/finances/pnl${query({ scope, months, period, from, to, timezone, name, format, order })}`);
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
// ── Raw provider payloads ──
|
|
@@ -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;
|
package/src/mcp.js
CHANGED
|
@@ -55,6 +55,30 @@ 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
|
+
},
|
|
58
82
|
{
|
|
59
83
|
name: 'finance_summary',
|
|
60
84
|
description: 'Balance sheet (assets, debt, net by currency), cashflow for the window, top spending categories, and the debt-and-income position.',
|
|
@@ -427,7 +451,7 @@ export async function handleMessage(message, ctx) {
|
|
|
427
451
|
capabilities: { tools: {} },
|
|
428
452
|
serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
|
|
429
453
|
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.',
|
|
454
|
+
'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
455
|
});
|
|
432
456
|
case 'notifications/initialized':
|
|
433
457
|
case 'notifications/cancelled':
|