@profullstack/coinpay 0.8.0 → 0.10.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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Finances — the merchant's money in one place.
3
+ *
4
+ * Read-side wrappers over the CoinPay API for everything that has a dollar
5
+ * sign on it: linked bank and card accounts (SimpleFIN / Plaid), the ledger,
6
+ * crypto payments, card payments, escrows, invoices and payouts. Plus
7
+ * `buildFinanceSnapshot`, a pure function that turns those responses into the
8
+ * headline numbers a dashboard wants (earnings, commission paid, refunds,
9
+ * cashflow, outstanding invoices, escrow held).
10
+ *
11
+ * Every call needs the merchant session token (`coinpay login`), not a
12
+ * business API key: the bank-data routes deliberately refuse API keys.
13
+ */
14
+
15
+ const CRYPTO_SUCCESS = new Set(['confirmed', 'completed', 'forwarded', 'forwarding']);
16
+ const CRYPTO_FAILED = new Set(['failed', 'expired', 'forwarding_failed', 'settle_failed', 'settlement_failed']);
17
+ const CRYPTO_PENDING = new Set(['pending', 'detected', 'confirming']);
18
+ const CARD_SUCCESS = new Set(['completed', 'succeeded']);
19
+ const CARD_FAILED = new Set(['failed', 'canceled', 'cancelled', 'requires_payment_method']);
20
+ const CARD_REFUNDED = new Set(['refunded', 'partially_refunded']);
21
+
22
+ function query(params) {
23
+ const search = new URLSearchParams();
24
+ for (const [key, value] of Object.entries(params)) {
25
+ if (value === undefined || value === null || value === '') continue;
26
+ search.set(key, String(value));
27
+ }
28
+ const s = search.toString();
29
+ return s ? `?${s}` : '';
30
+ }
31
+
32
+ /** Map a day window onto the analytics `period` values the API understands. */
33
+ export function periodForDays(days) {
34
+ if (!Number.isFinite(days) || days <= 0) return 'all';
35
+ if (days <= 7) return '7d';
36
+ if (days <= 30) return '30d';
37
+ if (days <= 90) return '90d';
38
+ if (days <= 365) return '1y';
39
+ return 'all';
40
+ }
41
+
42
+ // ── Bank & card accounts (SimpleFIN / Plaid) ──
43
+
44
+ /** Balance sheet + cashflow headline. `days` sets the cashflow window. */
45
+ export async function getFinanceSummary(client, { days = 30, includeHidden = false } = {}) {
46
+ const data = await client.request(`/finances/summary${query({ days, hidden: includeHidden ? 1 : undefined })}`);
47
+ return data.summary;
48
+ }
49
+
50
+ /** Every linked account with its current balance. */
51
+ export async function listFinanceAccounts(client, { includeHidden = false } = {}) {
52
+ const data = await client.request(`/finances/accounts${query({ hidden: includeHidden ? 1 : undefined })}`);
53
+ return data.accounts || [];
54
+ }
55
+
56
+ /**
57
+ * Every transaction in the window, not just the first page.
58
+ *
59
+ * The API caps a page well below what it will report as `total` (ask for 5000
60
+ * and you get 500), so a single call quietly returns a partial ledger. That is
61
+ * fine for a dashboard and useless for books: a report that silently drops
62
+ * two thirds of the year cannot be reconciled against anything, and nothing in
63
+ * the output would say so.
64
+ */
65
+ export async function listAllFinanceTransactions(client, { startDate, endDate, pageSize = 500, max = 20000 } = {}) {
66
+ const rows = [];
67
+ let offset = 0;
68
+ let total = 0;
69
+
70
+ for (;;) {
71
+ // Both bounds are forwarded. An end date that was accepted here and
72
+ // dropped on the way to the API would return rows past the window and
73
+ // report the window as complete.
74
+ const page = await listFinanceTransactions(client, { limit: pageSize, offset, startDate, endDate });
75
+ const batch = page?.rows ?? [];
76
+ total = Number(page?.total) || total;
77
+ rows.push(...batch);
78
+ offset += batch.length;
79
+ // Stop on a short page as well as on the count: a `total` that disagrees
80
+ // with what is actually returned must not spin this forever.
81
+ if (!batch.length || rows.length >= total || rows.length >= max) break;
82
+ }
83
+
84
+ return { rows, total: total || rows.length, complete: rows.length >= total };
85
+ }
86
+
87
+ /** The ledger, newest first. Returns `{ rows, total, limit, offset }`. */
88
+ export async function listFinanceTransactions(client, filters = {}) {
89
+ return client.request(
90
+ `/finances/transactions${query({
91
+ account: filters.accountId,
92
+ search: filters.search,
93
+ category: filters.category,
94
+ start: filters.startDate,
95
+ end: filters.endDate,
96
+ pending: filters.includePending === false ? 0 : undefined,
97
+ limit: filters.limit,
98
+ offset: filters.offset,
99
+ })}`,
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Correct one account: which side of the books it is on, what kind it is, or
105
+ * whether it counts at all.
106
+ *
107
+ * Every field here is an operator correction stored beside the imported value,
108
+ * never over it, so the next sync re-deriving its guess cannot clobber the
109
+ * correction. Pass `null` for an override to hand the account back to the
110
+ * heuristic. Balances and transactions are deliberately not writable.
111
+ */
112
+ export async function updateFinanceAccount(client, accountId, { kind, scope, hidden } = {}) {
113
+ const body = {};
114
+ if (kind !== undefined) body.kind_override = kind;
115
+ if (scope !== undefined) body.scope_override = scope;
116
+ if (hidden !== undefined) body.is_hidden = hidden;
117
+ if (Object.keys(body).length === 0) throw new Error('Nothing to update');
118
+ const data = await client.request(`/finances/accounts/${encodeURIComponent(accountId)}`, {
119
+ method: 'PATCH',
120
+ body: JSON.stringify(body),
121
+ });
122
+ return data.account;
123
+ }
124
+
125
+ /** Linked institutions and their last sync outcome. */
126
+ export async function listFinanceConnections(client) {
127
+ return client.request('/finances/connections');
128
+ }
129
+
130
+ /**
131
+ * Pull fresh balances and transactions from the bank bridge. This spends
132
+ * request budget (SimpleFIN allows ~24/day per connection), so it is a
133
+ * deliberate action, never something a screen does on its own.
134
+ */
135
+ export async function syncFinances(client, { days, connectionId } = {}) {
136
+ return client.request('/finances/sync', {
137
+ method: 'POST',
138
+ body: JSON.stringify({ days, connectionId }),
139
+ });
140
+ }
141
+
142
+ // ── Payments, cards, payouts ──
143
+
144
+ /** Merchant dashboard headline (plan, commission rate, recent payments). */
145
+ export async function getDashboardStats(client, { businessId } = {}) {
146
+ return client.request(`/dashboard/stats${query({ business_id: businessId })}`);
147
+ }
148
+
149
+ /** Crypto + card analytics for a period (`7d`, `30d`, `90d`, `1y`, `all`). */
150
+ export async function getFinanceAnalytics(client, { period = '30d', businessId } = {}) {
151
+ const data = await client.request(`/stripe/analytics${query({ period, business_id: businessId })}`);
152
+ return data.analytics;
153
+ }
154
+
155
+ /** Crypto payments across every business the merchant can see. */
156
+ export async function listCryptoPayments(client, { limit = 100, offset = 0, status, businessId, dateFrom, dateTo } = {}) {
157
+ return client.request(`/payments${query({ limit, offset, status, business_id: businessId, date_from: dateFrom, date_to: dateTo })}`);
158
+ }
159
+
160
+ /** Card (Stripe) charges across every business the merchant can see. */
161
+ export async function listCardTransactions(client, { limit = 100, offset = 0, status, businessId, dateFrom, dateTo } = {}) {
162
+ return client.request(`/stripe/transactions${query({ limit, offset, status, business_id: businessId, date_from: dateFrom, date_to: dateTo })}`);
163
+ }
164
+
165
+ /** Card payouts to the merchant's bank. */
166
+ export async function listCardPayouts(client, { limit = 50, offset = 0, status } = {}) {
167
+ return client.request(`/stripe/payouts${query({ limit, offset, status })}`);
168
+ }
169
+
170
+ // ── Snapshot ──
171
+
172
+ function num(value) {
173
+ const n = typeof value === 'number' ? value : parseFloat(value ?? '');
174
+ return Number.isFinite(n) ? n : 0;
175
+ }
176
+
177
+ function within(dateLike, since) {
178
+ if (!since) return true;
179
+ const t = new Date(dateLike || 0).getTime();
180
+ return Number.isFinite(t) && t >= since.getTime();
181
+ }
182
+
183
+ function round2(n) {
184
+ return Math.round(n * 100) / 100;
185
+ }
186
+
187
+ /** USD value of a crypto payment's fee, pro-rated from the crypto amounts. */
188
+ export function cryptoFeeUsd(payment) {
189
+ const fee = num(payment.fee_amount);
190
+ const crypto = num(payment.amount_crypto ?? payment.crypto_amount);
191
+ const usd = num(payment.amount_usd ?? payment.amount);
192
+ if (fee > 0 && crypto > 0 && usd > 0) return (fee / crypto) * usd;
193
+ return 0;
194
+ }
195
+
196
+ /**
197
+ * Turn raw API responses into the numbers a dashboard shows.
198
+ *
199
+ * Pure: no I/O, so it is unit-testable and the TUI can re-run it on every
200
+ * refresh without touching the network. Any input may be missing (a route
201
+ * that failed) and the corresponding section is then empty rather than the
202
+ * whole snapshot failing.
203
+ */
204
+ export function buildFinanceSnapshot(raw = {}, { days = 30, now = new Date() } = {}) {
205
+ const since = Number.isFinite(days) && days > 0 ? new Date(now.getTime() - days * 86400000) : null;
206
+ const errors = { ...(raw.errors || {}) };
207
+
208
+ // ── Crypto payments ──
209
+ const payments = raw.payments?.payments || [];
210
+ const windowPayments = payments.filter((p) => within(p.created_at, since));
211
+ const cryptoOk = windowPayments.filter((p) => CRYPTO_SUCCESS.has(String(p.status).toLowerCase()));
212
+ const cryptoFailed = windowPayments.filter((p) => CRYPTO_FAILED.has(String(p.status).toLowerCase()));
213
+ const cryptoPending = windowPayments.filter((p) => CRYPTO_PENDING.has(String(p.status).toLowerCase()));
214
+ const crypto = {
215
+ volumeUsd: round2(cryptoOk.reduce((s, p) => s + num(p.amount_usd), 0)),
216
+ feesUsd: round2(cryptoOk.reduce((s, p) => s + cryptoFeeUsd(p), 0)),
217
+ successful: cryptoOk.length,
218
+ failed: cryptoFailed.length,
219
+ pending: cryptoPending.length,
220
+ total: windowPayments.length,
221
+ byChain: {},
222
+ // The list is a page; the server-side analytics below cover the full window.
223
+ partial: Boolean(raw.payments?.pagination?.has_more),
224
+ };
225
+ for (const p of cryptoOk) {
226
+ const chain = p.currency || 'unknown';
227
+ crypto.byChain[chain] = round2((crypto.byChain[chain] || 0) + num(p.amount_usd));
228
+ }
229
+
230
+ // ── Card payments ──
231
+ const cardTx = raw.cardTransactions?.transactions || [];
232
+ const windowCards = cardTx.filter((t) => within(t.created_at, since));
233
+ const cardOk = windowCards.filter((t) => CARD_SUCCESS.has(String(t.status).toLowerCase()));
234
+ const cardFailed = windowCards.filter((t) => CARD_FAILED.has(String(t.status).toLowerCase()));
235
+ const cardRefunded = windowCards.filter((t) => CARD_REFUNDED.has(String(t.status).toLowerCase()));
236
+ const cents = (t, key) => num(t[key]) / 100;
237
+ const card = {
238
+ volumeUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'amount_cents'), 0)),
239
+ platformFeesUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'platform_fee_amount'), 0)),
240
+ stripeFeesUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'stripe_fee_amount'), 0)),
241
+ netUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'net_to_merchant'), 0)),
242
+ refundedUsd: round2(cardRefunded.reduce((s, t) => s + cents(t, 'amount_cents'), 0)),
243
+ successful: cardOk.length,
244
+ failed: cardFailed.length,
245
+ refunded: cardRefunded.length,
246
+ total: windowCards.length,
247
+ partial: Boolean(raw.cardTransactions?.pagination?.has_more),
248
+ };
249
+
250
+ // ── Server-side analytics (complete over the window, unlike the pages above) ──
251
+ const analytics = raw.analytics || null;
252
+ const series = (analytics?.series?.points || []).map((pt) => ({
253
+ label: pt.label,
254
+ volumeUsd: num(pt.total_volume_usd),
255
+ cryptoUsd: num(pt.crypto_volume_usd),
256
+ cardUsd: num(pt.card_volume_usd),
257
+ commissionUsd: num(pt.total_commission_usd),
258
+ count: num(pt.total_count),
259
+ }));
260
+
261
+ const grossVolumeUsd = analytics
262
+ ? num(analytics.combined?.total_volume_usd)
263
+ : round2(crypto.volumeUsd + card.volumeUsd);
264
+ const commissionUsd = analytics
265
+ ? num(analytics.combined?.total_fees_usd)
266
+ : round2(crypto.feesUsd + card.platformFeesUsd);
267
+
268
+ // ── Escrow ──
269
+ const escrows = raw.escrows?.escrows || raw.escrows?.data || (Array.isArray(raw.escrows) ? raw.escrows : []);
270
+ const escrowStatus = (e) => String(e.status || '').toLowerCase();
271
+ const escrowRefunded = escrows.filter((e) => escrowStatus(e) === 'refunded' && within(e.settled_at || e.refunded_at || e.updated_at, since));
272
+ const escrowHeld = escrows.filter((e) => ['funded', 'disputed', 'pending_release'].includes(escrowStatus(e)));
273
+ const escrowReleased = escrows.filter((e) => ['released', 'settled', 'completed'].includes(escrowStatus(e)) && within(e.settled_at || e.released_at || e.updated_at, since));
274
+ const escrowFeeUsd = (e) => {
275
+ if (!e.fee_tx_hash) return 0;
276
+ const fee = num(e.fee_amount);
277
+ const amount = num(e.amount);
278
+ return fee > 0 && amount > 0 ? (fee / amount) * num(e.amount_usd) : 0;
279
+ };
280
+ const escrow = {
281
+ heldUsd: round2(escrowHeld.reduce((s, e) => s + num(e.amount_usd), 0)),
282
+ held: escrowHeld.length,
283
+ releasedUsd: round2(escrowReleased.reduce((s, e) => s + num(e.amount_usd), 0)),
284
+ released: escrowReleased.length,
285
+ refundedUsd: round2(escrowRefunded.reduce((s, e) => s + num(e.amount_usd), 0)),
286
+ refunded: escrowRefunded.length,
287
+ feesUsd: round2(escrowReleased.reduce((s, e) => s + escrowFeeUsd(e), 0)),
288
+ total: escrows.length,
289
+ };
290
+
291
+ // ── Invoices ──
292
+ const invoices = raw.invoices?.invoices || raw.invoices?.data || (Array.isArray(raw.invoices) ? raw.invoices : []);
293
+ const invStatus = (i) => String(i.status || '').toLowerCase();
294
+ const isOverdue = (i) => ['sent', 'overdue'].includes(invStatus(i)) && i.due_date && new Date(i.due_date).getTime() < now.getTime();
295
+ const invoiceTotals = { draft: 0, outstanding: 0, overdue: 0, paid: 0, cancelled: 0 };
296
+ const invoiceCounts = { draft: 0, outstanding: 0, overdue: 0, paid: 0, cancelled: 0 };
297
+ for (const inv of invoices) {
298
+ const amount = num(inv.amount);
299
+ const status = invStatus(inv);
300
+ let bucket;
301
+ if (status === 'draft') bucket = 'draft';
302
+ else if (status === 'paid') {
303
+ if (!within(inv.paid_at || inv.updated_at, since)) continue;
304
+ bucket = 'paid';
305
+ } else if (status === 'cancelled' || status === 'canceled') bucket = 'cancelled';
306
+ else if (isOverdue(inv)) bucket = 'overdue';
307
+ else bucket = 'outstanding';
308
+ invoiceTotals[bucket] = round2(invoiceTotals[bucket] + amount);
309
+ invoiceCounts[bucket] += 1;
310
+ }
311
+
312
+ // ── Payouts (card rail → bank) ──
313
+ const payouts = raw.payouts?.payouts || [];
314
+ const windowPayouts = payouts.filter((p) => within(p.created_at, since));
315
+ const payout = {
316
+ paidUsd: round2(windowPayouts.filter((p) => String(p.status).toLowerCase() === 'paid').reduce((s, p) => s + num(p.amount_cents) / 100, 0)),
317
+ pendingUsd: round2(windowPayouts.filter((p) => ['pending', 'in_transit'].includes(String(p.status).toLowerCase())).reduce((s, p) => s + num(p.amount_cents) / 100, 0)),
318
+ count: windowPayouts.length,
319
+ };
320
+
321
+ // ── Bank & cards ──
322
+ const summary = raw.summary || null;
323
+ const accounts = (raw.accounts || []).slice().sort((a, b) => Math.abs(num(b.display_balance ?? b.balance)) - Math.abs(num(a.display_balance ?? a.balance)));
324
+ const primary = summary?.totals?.find((t) => t.currency === summary.primaryCurrency) || summary?.totals?.[0] || null;
325
+ const bank = {
326
+ currency: summary?.primaryCurrency || primary?.currency || 'USD',
327
+ assets: round2(num(primary?.assets)),
328
+ liabilities: round2(num(primary?.liabilities)),
329
+ net: round2(num(primary?.net)),
330
+ accountCount: summary?.accountCount ?? accounts.length,
331
+ hiddenCount: summary?.hiddenCount ?? 0,
332
+ transactionCount: summary?.transactionCount ?? 0,
333
+ cashflow: {
334
+ moneyIn: round2(num(summary?.cashflow?.moneyIn)),
335
+ moneyOut: round2(num(summary?.cashflow?.moneyOut)),
336
+ net: round2(num(summary?.cashflow?.net)),
337
+ transactions: summary?.cashflow?.transactions ?? 0,
338
+ },
339
+ byKind: summary?.byKind || [],
340
+ byInstitution: (summary?.byInstitution || []).slice().sort((a, b) => (b.assets + b.liabilities) - (a.assets + a.liabilities)),
341
+ topCategories: summary?.topCategories || [],
342
+ newestTransaction: summary?.newestTransaction || null,
343
+ accounts,
344
+ creditCards: accounts.filter((a) => (a.effective_kind || a.kind) === 'credit'),
345
+ connections: raw.connections?.connections || [],
346
+ plaidEnabled: Boolean(raw.connections?.plaidEnabled),
347
+ ledger: raw.transactions?.rows || [],
348
+ ledgerTotal: raw.transactions?.total ?? 0,
349
+ /**
350
+ * Whether the ledger above is the whole window or one page of it.
351
+ *
352
+ * Carried rather than inferred from lengths: a caller cannot tell a short
353
+ * final page from a truncated one, and anything printing a coverage line
354
+ * has to know which it is holding before it makes a claim about the period.
355
+ */
356
+ ledgerComplete: raw.transactions?.complete ?? ((raw.transactions?.rows?.length ?? 0) >= (raw.transactions?.total ?? 0)),
357
+ };
358
+
359
+ const refundsUsd = round2(card.refundedUsd + escrow.refundedUsd);
360
+ const stats = raw.stats || null;
361
+
362
+ // Debt against income. Computed server-side, because it needs six months of
363
+ // rows and the ledger this snapshot carries is one page of the window.
364
+ // Absent when the summary route failed, and the screen says so rather than
365
+ // drawing zeros.
366
+ const position = summary?.position || null;
367
+
368
+ return {
369
+ generatedAt: now.toISOString(),
370
+ windowDays: days,
371
+ plan: stats?.plan || null,
372
+ businesses: stats?.businesses || [],
373
+ earnings: {
374
+ grossVolumeUsd: round2(grossVolumeUsd),
375
+ cryptoVolumeUsd: analytics ? num(analytics.crypto?.total_volume_usd) : crypto.volumeUsd,
376
+ cardVolumeUsd: analytics ? num(analytics.card?.total_volume_usd) : card.volumeUsd,
377
+ commissionUsd: round2(commissionUsd),
378
+ stripeFeesUsd: card.stripeFeesUsd,
379
+ refundsUsd,
380
+ netUsd: round2(grossVolumeUsd - commissionUsd - card.stripeFeesUsd - refundsUsd),
381
+ transactions: analytics ? num(analytics.combined?.successful_transactions) : crypto.successful + card.successful,
382
+ failed: analytics ? num(analytics.combined?.failed_transactions) : crypto.failed + card.failed,
383
+ failureRate: analytics ? num(analytics.combined?.failure_rate) : 0,
384
+ },
385
+ series,
386
+ crypto,
387
+ card,
388
+ escrow,
389
+ invoices: { totals: invoiceTotals, counts: invoiceCounts, rows: invoices },
390
+ payout,
391
+ bank,
392
+ position,
393
+ recent: {
394
+ payments: payments.slice(0, 50),
395
+ cards: cardTx.slice(0, 50),
396
+ escrows: escrows.slice(0, 50),
397
+ },
398
+ errors,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Fetch everything and build a snapshot. Routes fail independently: a 500
404
+ * from one of them lands in `snapshot.errors[name]` and its section is empty.
405
+ */
406
+ export async function collectFinanceSnapshot(client, { days = 30, limit = 100, businessId, allTransactions = false } = {}) {
407
+ const since = Number.isFinite(days) && days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : undefined;
408
+ const sources = {
409
+ summary: () => getFinanceSummary(client, { days }),
410
+ accounts: () => listFinanceAccounts(client),
411
+ // The dashboard wants one page and wants it now; a report wants the whole
412
+ // window, because a document that silently holds a fifth of the year
413
+ // cannot be reconciled against anything.
414
+ transactions: () => (allTransactions
415
+ ? listAllFinanceTransactions(client, { startDate: since })
416
+ : listFinanceTransactions(client, { limit, startDate: since })),
417
+ connections: () => listFinanceConnections(client),
418
+ stats: () => getDashboardStats(client, { businessId }),
419
+ analytics: () => getFinanceAnalytics(client, { period: periodForDays(days), businessId }),
420
+ payments: () => listCryptoPayments(client, { limit, businessId, dateFrom: since }),
421
+ cardTransactions: () => listCardTransactions(client, { limit, businessId, dateFrom: since }),
422
+ // Raw route on purpose: the SDK's listEscrows camel-cases fields and the
423
+ // snapshot reads the API's snake_case shape like every other source.
424
+ escrows: () => client.request(`/escrow${query({ limit })}`),
425
+ invoices: () => client.request('/invoices'),
426
+ payouts: () => listCardPayouts(client, { limit }),
427
+ };
428
+
429
+ const names = Object.keys(sources);
430
+ const settled = await Promise.allSettled(names.map((name) => sources[name]()));
431
+ const raw = { errors: {} };
432
+ settled.forEach((result, i) => {
433
+ if (result.status === 'fulfilled') raw[names[i]] = result.value;
434
+ else raw.errors[names[i]] = result.reason?.message || String(result.reason);
435
+ });
436
+
437
+ // One failing source is a hole in the picture; every source failing means
438
+ // the API or the session is gone, and a page of zeros would be a lie.
439
+ if (Object.keys(raw.errors).length === names.length) {
440
+ const first = raw.errors[names[0]];
441
+ throw new Error(`Could not load finances: ${first}`);
442
+ }
443
+
444
+ return buildFinanceSnapshot(raw, { days });
445
+ }
446
+
447
+ /**
448
+ * Subscribe to the merchant's live payment stream (server-sent events).
449
+ *
450
+ * Takes the API base URL and the merchant session token explicitly (the
451
+ * client keeps its key private). `onEvent` receives parsed events (`connected`, `heartbeat`, `payment_*`).
452
+ * Returns a function that closes the stream. Reconnects itself with backoff
453
+ * until closed, so a dropped connection is a gap, not the end.
454
+ */
455
+ export function subscribeToPayments({ baseUrl, token, onEvent, onStatus, businessId } = {}) {
456
+ if (!baseUrl || !token) throw new Error('subscribeToPayments needs baseUrl and token');
457
+ baseUrl = baseUrl.replace(/\/$/, '');
458
+ let closed = false;
459
+ let controller = null;
460
+ let attempt = 0;
461
+
462
+ const status = (s, detail) => { try { onStatus?.(s, detail); } catch { /* listener error */ } };
463
+
464
+ async function connect() {
465
+ while (!closed) {
466
+ controller = new AbortController();
467
+ try {
468
+ status('connecting');
469
+ const res = await fetch(`${baseUrl}/realtime/payments${query({ businessId })}`, {
470
+ headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
471
+ signal: controller.signal,
472
+ });
473
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
474
+ attempt = 0;
475
+ status('connected');
476
+ const reader = res.body.getReader();
477
+ const decoder = new TextDecoder();
478
+ let buffer = '';
479
+ for (;;) {
480
+ const { value, done } = await reader.read();
481
+ if (done) break;
482
+ buffer += decoder.decode(value, { stream: true });
483
+ let idx;
484
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
485
+ const chunk = buffer.slice(0, idx);
486
+ buffer = buffer.slice(idx + 2);
487
+ const data = chunk.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
488
+ if (!data) continue;
489
+ try { onEvent?.(JSON.parse(data)); } catch { /* not JSON */ }
490
+ }
491
+ }
492
+ if (!closed) status('disconnected');
493
+ } catch (err) {
494
+ if (closed) return;
495
+ status('error', err?.message || String(err));
496
+ }
497
+ if (closed) return;
498
+ attempt += 1;
499
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(attempt, 5));
500
+ await new Promise((r) => setTimeout(r, delay));
501
+ }
502
+ }
503
+
504
+ void connect();
505
+
506
+ return () => {
507
+ closed = true;
508
+ try { controller?.abort(); } catch { /* already gone */ }
509
+ status('closed');
510
+ };
511
+ }
package/src/index.d.ts CHANGED
@@ -51,6 +51,7 @@ export {
51
51
  listInvoices,
52
52
  updateInvoice,
53
53
  deleteInvoice,
54
+ publishInvoice,
54
55
  sendInvoice,
55
56
  getInvoicePaymentData,
56
57
  InvoiceStatus,
@@ -146,3 +147,6 @@ export type {
146
147
 
147
148
  import { CoinPayClient } from './client.js';
148
149
  export default CoinPayClient;
150
+
151
+ export * from './finances.js';
152
+ export * from './finances-reports.js';
package/src/index.js CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  listInvoices,
79
79
  updateInvoice,
80
80
  deleteInvoice,
81
+ publishInvoice,
81
82
  sendInvoice,
82
83
  getInvoicePaymentData,
83
84
  InvoiceStatus,
@@ -180,6 +181,23 @@ import {
180
181
  CHAIN_IDS,
181
182
  } from './x402.js';
182
183
 
184
+ import {
185
+ getFinanceSummary,
186
+ listFinanceAccounts,
187
+ listFinanceTransactions,
188
+ listFinanceConnections,
189
+ syncFinances,
190
+ getDashboardStats,
191
+ getFinanceAnalytics,
192
+ listCryptoPayments,
193
+ listCardTransactions,
194
+ listCardPayouts,
195
+ buildFinanceSnapshot,
196
+ collectFinanceSnapshot,
197
+ subscribeToPayments,
198
+ periodForDays,
199
+ } from './finances.js';
200
+
183
201
  export {
184
202
  // Client
185
203
  CoinPayClient,
@@ -209,6 +227,7 @@ export {
209
227
  listInvoices,
210
228
  updateInvoice,
211
229
  deleteInvoice,
230
+ publishInvoice,
212
231
  sendInvoice,
213
232
  getInvoicePaymentData,
214
233
  InvoiceStatus,
@@ -284,6 +303,22 @@ export {
284
303
  // Lightning
285
304
  LightningClient,
286
305
 
306
+ // Finances (bank feeds, earnings, commission, refunds)
307
+ getFinanceSummary,
308
+ listFinanceAccounts,
309
+ listFinanceTransactions,
310
+ listFinanceConnections,
311
+ syncFinances,
312
+ getDashboardStats,
313
+ getFinanceAnalytics,
314
+ listCryptoPayments,
315
+ listCardTransactions,
316
+ listCardPayouts,
317
+ buildFinanceSnapshot,
318
+ collectFinanceSnapshot,
319
+ subscribeToPayments,
320
+ periodForDays,
321
+
287
322
  // Auth
288
323
  registerMerchant,
289
324
  loginMerchant,
@@ -309,3 +344,6 @@ export {
309
344
  };
310
345
 
311
346
  export default CoinPayClient;
347
+
348
+ // Finance reports, sync jobs, coverage and the statement library.
349
+ export * from './finances-reports.js';
package/src/invoices.d.ts CHANGED
@@ -73,5 +73,6 @@ export function listInvoices(client: CoinPayClient, filters?: ListInvoicesParams
73
73
  export function getInvoice(client: CoinPayClient, id: string): Promise<Invoice>;
74
74
  export function updateInvoice(client: CoinPayClient, id: string, updates: UpdateInvoiceParams): Promise<Invoice>;
75
75
  export function deleteInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
76
+ export function publishInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
76
77
  export function sendInvoice(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
77
78
  export function getInvoicePaymentData(client: CoinPayClient, id: string): Promise<Record<string, unknown>>;
package/src/invoices.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @example
9
9
  * import { CoinPayClient } from '@profullstack/coinpay';
10
- * import { createInvoice, sendInvoice, getInvoicePaymentData } from '@profullstack/coinpay';
10
+ * import { createInvoice, publishInvoice, sendInvoice, getInvoicePaymentData } from '@profullstack/coinpay';
11
11
  *
12
12
  * const client = new CoinPayClient({ apiKey: 'your-key' });
13
13
  *
@@ -179,6 +179,19 @@ export async function sendInvoice(client, id) {
179
179
  return data;
180
180
  }
181
181
 
182
+ /**
183
+ * Publish an invoice without emailing the client.
184
+ * Generates live payment details that can be shared manually.
185
+ * @param {CoinPayClient} client
186
+ * @param {string} id - Draft invoice ID
187
+ * @returns {Promise<Object>} Publish result with invoice and payment link
188
+ */
189
+ export async function publishInvoice(client, id) {
190
+ return client.request(`/invoices/${encodeURIComponent(id)}/publish`, {
191
+ method: 'POST',
192
+ });
193
+ }
194
+
182
195
  /**
183
196
  * Get invoice payment data (public, no auth required)
184
197
  * Used by clients/customers to view payment instructions.
package/src/lightning.js CHANGED
@@ -26,16 +26,21 @@ export class LightningClient {
26
26
 
27
27
  /**
28
28
  * Enable Lightning for a wallet (provisions an LNbits custodial wallet).
29
+ *
29
30
  * @param {Object} params
30
31
  * @param {string} params.wallet_id - Wallet UUID
31
- * @param {string} params.mnemonic - BIP39 mnemonic
32
+ * @param {string} [params.mnemonic] - **Deprecated and ignored.** The server
33
+ * required a BIP39 mnemonic here, validated it, and never used it: the
34
+ * provisioned wallet is custodial, so there is no signer and nothing to
35
+ * derive (NEW-20). The seed reconstructs every key on every chain, and it
36
+ * is no longer sent. Stop passing it.
32
37
  * @param {string} [params.business_id] - Optional business UUID
33
38
  * @returns {Promise<Object>} The provisioned node record
34
39
  */
35
- async enableWallet({ wallet_id, mnemonic, business_id }) {
40
+ async enableWallet({ wallet_id, business_id }) {
36
41
  return this.#client.request('/lightning/nodes', {
37
42
  method: 'POST',
38
- body: JSON.stringify({ wallet_id, mnemonic, business_id }),
43
+ body: JSON.stringify({ wallet_id, business_id }),
39
44
  });
40
45
  }
41
46