@profullstack/coinpay 0.8.0 → 0.9.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,445 @@
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
+ /** The ledger, newest first. Returns `{ rows, total, limit, offset }`. */
57
+ export async function listFinanceTransactions(client, filters = {}) {
58
+ return client.request(
59
+ `/finances/transactions${query({
60
+ account: filters.accountId,
61
+ search: filters.search,
62
+ category: filters.category,
63
+ start: filters.startDate,
64
+ end: filters.endDate,
65
+ pending: filters.includePending === false ? 0 : undefined,
66
+ limit: filters.limit,
67
+ offset: filters.offset,
68
+ })}`,
69
+ );
70
+ }
71
+
72
+ /** Linked institutions and their last sync outcome. */
73
+ export async function listFinanceConnections(client) {
74
+ return client.request('/finances/connections');
75
+ }
76
+
77
+ /**
78
+ * Pull fresh balances and transactions from the bank bridge. This spends
79
+ * request budget (SimpleFIN allows ~24/day per connection), so it is a
80
+ * deliberate action, never something a screen does on its own.
81
+ */
82
+ export async function syncFinances(client, { days, connectionId } = {}) {
83
+ return client.request('/finances/sync', {
84
+ method: 'POST',
85
+ body: JSON.stringify({ days, connectionId }),
86
+ });
87
+ }
88
+
89
+ // ── Payments, cards, payouts ──
90
+
91
+ /** Merchant dashboard headline (plan, commission rate, recent payments). */
92
+ export async function getDashboardStats(client, { businessId } = {}) {
93
+ return client.request(`/dashboard/stats${query({ business_id: businessId })}`);
94
+ }
95
+
96
+ /** Crypto + card analytics for a period (`7d`, `30d`, `90d`, `1y`, `all`). */
97
+ export async function getFinanceAnalytics(client, { period = '30d', businessId } = {}) {
98
+ const data = await client.request(`/stripe/analytics${query({ period, business_id: businessId })}`);
99
+ return data.analytics;
100
+ }
101
+
102
+ /** Crypto payments across every business the merchant can see. */
103
+ export async function listCryptoPayments(client, { limit = 100, offset = 0, status, businessId, dateFrom, dateTo } = {}) {
104
+ return client.request(`/payments${query({ limit, offset, status, business_id: businessId, date_from: dateFrom, date_to: dateTo })}`);
105
+ }
106
+
107
+ /** Card (Stripe) charges across every business the merchant can see. */
108
+ export async function listCardTransactions(client, { limit = 100, offset = 0, status, businessId, dateFrom, dateTo } = {}) {
109
+ return client.request(`/stripe/transactions${query({ limit, offset, status, business_id: businessId, date_from: dateFrom, date_to: dateTo })}`);
110
+ }
111
+
112
+ /** Card payouts to the merchant's bank. */
113
+ export async function listCardPayouts(client, { limit = 50, offset = 0, status } = {}) {
114
+ return client.request(`/stripe/payouts${query({ limit, offset, status })}`);
115
+ }
116
+
117
+ // ── Snapshot ──
118
+
119
+ function num(value) {
120
+ const n = typeof value === 'number' ? value : parseFloat(value ?? '');
121
+ return Number.isFinite(n) ? n : 0;
122
+ }
123
+
124
+ function within(dateLike, since) {
125
+ if (!since) return true;
126
+ const t = new Date(dateLike || 0).getTime();
127
+ return Number.isFinite(t) && t >= since.getTime();
128
+ }
129
+
130
+ function round2(n) {
131
+ return Math.round(n * 100) / 100;
132
+ }
133
+
134
+ /** USD value of a crypto payment's fee, pro-rated from the crypto amounts. */
135
+ export function cryptoFeeUsd(payment) {
136
+ const fee = num(payment.fee_amount);
137
+ const crypto = num(payment.amount_crypto ?? payment.crypto_amount);
138
+ const usd = num(payment.amount_usd ?? payment.amount);
139
+ if (fee > 0 && crypto > 0 && usd > 0) return (fee / crypto) * usd;
140
+ return 0;
141
+ }
142
+
143
+ /**
144
+ * Turn raw API responses into the numbers a dashboard shows.
145
+ *
146
+ * Pure: no I/O, so it is unit-testable and the TUI can re-run it on every
147
+ * refresh without touching the network. Any input may be missing (a route
148
+ * that failed) and the corresponding section is then empty rather than the
149
+ * whole snapshot failing.
150
+ */
151
+ export function buildFinanceSnapshot(raw = {}, { days = 30, now = new Date() } = {}) {
152
+ const since = Number.isFinite(days) && days > 0 ? new Date(now.getTime() - days * 86400000) : null;
153
+ const errors = { ...(raw.errors || {}) };
154
+
155
+ // ── Crypto payments ──
156
+ const payments = raw.payments?.payments || [];
157
+ const windowPayments = payments.filter((p) => within(p.created_at, since));
158
+ const cryptoOk = windowPayments.filter((p) => CRYPTO_SUCCESS.has(String(p.status).toLowerCase()));
159
+ const cryptoFailed = windowPayments.filter((p) => CRYPTO_FAILED.has(String(p.status).toLowerCase()));
160
+ const cryptoPending = windowPayments.filter((p) => CRYPTO_PENDING.has(String(p.status).toLowerCase()));
161
+ const crypto = {
162
+ volumeUsd: round2(cryptoOk.reduce((s, p) => s + num(p.amount_usd), 0)),
163
+ feesUsd: round2(cryptoOk.reduce((s, p) => s + cryptoFeeUsd(p), 0)),
164
+ successful: cryptoOk.length,
165
+ failed: cryptoFailed.length,
166
+ pending: cryptoPending.length,
167
+ total: windowPayments.length,
168
+ byChain: {},
169
+ // The list is a page; the server-side analytics below cover the full window.
170
+ partial: Boolean(raw.payments?.pagination?.has_more),
171
+ };
172
+ for (const p of cryptoOk) {
173
+ const chain = p.currency || 'unknown';
174
+ crypto.byChain[chain] = round2((crypto.byChain[chain] || 0) + num(p.amount_usd));
175
+ }
176
+
177
+ // ── Card payments ──
178
+ const cardTx = raw.cardTransactions?.transactions || [];
179
+ const windowCards = cardTx.filter((t) => within(t.created_at, since));
180
+ const cardOk = windowCards.filter((t) => CARD_SUCCESS.has(String(t.status).toLowerCase()));
181
+ const cardFailed = windowCards.filter((t) => CARD_FAILED.has(String(t.status).toLowerCase()));
182
+ const cardRefunded = windowCards.filter((t) => CARD_REFUNDED.has(String(t.status).toLowerCase()));
183
+ const cents = (t, key) => num(t[key]) / 100;
184
+ const card = {
185
+ volumeUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'amount_cents'), 0)),
186
+ platformFeesUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'platform_fee_amount'), 0)),
187
+ stripeFeesUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'stripe_fee_amount'), 0)),
188
+ netUsd: round2(cardOk.reduce((s, t) => s + cents(t, 'net_to_merchant'), 0)),
189
+ refundedUsd: round2(cardRefunded.reduce((s, t) => s + cents(t, 'amount_cents'), 0)),
190
+ successful: cardOk.length,
191
+ failed: cardFailed.length,
192
+ refunded: cardRefunded.length,
193
+ total: windowCards.length,
194
+ partial: Boolean(raw.cardTransactions?.pagination?.has_more),
195
+ };
196
+
197
+ // ── Server-side analytics (complete over the window, unlike the pages above) ──
198
+ const analytics = raw.analytics || null;
199
+ const series = (analytics?.series?.points || []).map((pt) => ({
200
+ label: pt.label,
201
+ volumeUsd: num(pt.total_volume_usd),
202
+ cryptoUsd: num(pt.crypto_volume_usd),
203
+ cardUsd: num(pt.card_volume_usd),
204
+ commissionUsd: num(pt.total_commission_usd),
205
+ count: num(pt.total_count),
206
+ }));
207
+
208
+ const grossVolumeUsd = analytics
209
+ ? num(analytics.combined?.total_volume_usd)
210
+ : round2(crypto.volumeUsd + card.volumeUsd);
211
+ const commissionUsd = analytics
212
+ ? num(analytics.combined?.total_fees_usd)
213
+ : round2(crypto.feesUsd + card.platformFeesUsd);
214
+
215
+ // ── Escrow ──
216
+ const escrows = raw.escrows?.escrows || raw.escrows?.data || (Array.isArray(raw.escrows) ? raw.escrows : []);
217
+ const escrowStatus = (e) => String(e.status || '').toLowerCase();
218
+ const escrowRefunded = escrows.filter((e) => escrowStatus(e) === 'refunded' && within(e.settled_at || e.refunded_at || e.updated_at, since));
219
+ const escrowHeld = escrows.filter((e) => ['funded', 'disputed', 'pending_release'].includes(escrowStatus(e)));
220
+ const escrowReleased = escrows.filter((e) => ['released', 'settled', 'completed'].includes(escrowStatus(e)) && within(e.settled_at || e.released_at || e.updated_at, since));
221
+ const escrowFeeUsd = (e) => {
222
+ if (!e.fee_tx_hash) return 0;
223
+ const fee = num(e.fee_amount);
224
+ const amount = num(e.amount);
225
+ return fee > 0 && amount > 0 ? (fee / amount) * num(e.amount_usd) : 0;
226
+ };
227
+ const escrow = {
228
+ heldUsd: round2(escrowHeld.reduce((s, e) => s + num(e.amount_usd), 0)),
229
+ held: escrowHeld.length,
230
+ releasedUsd: round2(escrowReleased.reduce((s, e) => s + num(e.amount_usd), 0)),
231
+ released: escrowReleased.length,
232
+ refundedUsd: round2(escrowRefunded.reduce((s, e) => s + num(e.amount_usd), 0)),
233
+ refunded: escrowRefunded.length,
234
+ feesUsd: round2(escrowReleased.reduce((s, e) => s + escrowFeeUsd(e), 0)),
235
+ total: escrows.length,
236
+ };
237
+
238
+ // ── Invoices ──
239
+ const invoices = raw.invoices?.invoices || raw.invoices?.data || (Array.isArray(raw.invoices) ? raw.invoices : []);
240
+ const invStatus = (i) => String(i.status || '').toLowerCase();
241
+ const isOverdue = (i) => ['sent', 'overdue'].includes(invStatus(i)) && i.due_date && new Date(i.due_date).getTime() < now.getTime();
242
+ const invoiceTotals = { draft: 0, outstanding: 0, overdue: 0, paid: 0, cancelled: 0 };
243
+ const invoiceCounts = { draft: 0, outstanding: 0, overdue: 0, paid: 0, cancelled: 0 };
244
+ for (const inv of invoices) {
245
+ const amount = num(inv.amount);
246
+ const status = invStatus(inv);
247
+ let bucket;
248
+ if (status === 'draft') bucket = 'draft';
249
+ else if (status === 'paid') {
250
+ if (!within(inv.paid_at || inv.updated_at, since)) continue;
251
+ bucket = 'paid';
252
+ } else if (status === 'cancelled' || status === 'canceled') bucket = 'cancelled';
253
+ else if (isOverdue(inv)) bucket = 'overdue';
254
+ else bucket = 'outstanding';
255
+ invoiceTotals[bucket] = round2(invoiceTotals[bucket] + amount);
256
+ invoiceCounts[bucket] += 1;
257
+ }
258
+
259
+ // ── Payouts (card rail → bank) ──
260
+ const payouts = raw.payouts?.payouts || [];
261
+ const windowPayouts = payouts.filter((p) => within(p.created_at, since));
262
+ const payout = {
263
+ paidUsd: round2(windowPayouts.filter((p) => String(p.status).toLowerCase() === 'paid').reduce((s, p) => s + num(p.amount_cents) / 100, 0)),
264
+ pendingUsd: round2(windowPayouts.filter((p) => ['pending', 'in_transit'].includes(String(p.status).toLowerCase())).reduce((s, p) => s + num(p.amount_cents) / 100, 0)),
265
+ count: windowPayouts.length,
266
+ };
267
+
268
+ // ── Bank & cards ──
269
+ const summary = raw.summary || null;
270
+ const accounts = (raw.accounts || []).slice().sort((a, b) => Math.abs(num(b.display_balance ?? b.balance)) - Math.abs(num(a.display_balance ?? a.balance)));
271
+ const primary = summary?.totals?.find((t) => t.currency === summary.primaryCurrency) || summary?.totals?.[0] || null;
272
+ const bank = {
273
+ currency: summary?.primaryCurrency || primary?.currency || 'USD',
274
+ assets: round2(num(primary?.assets)),
275
+ liabilities: round2(num(primary?.liabilities)),
276
+ net: round2(num(primary?.net)),
277
+ accountCount: summary?.accountCount ?? accounts.length,
278
+ hiddenCount: summary?.hiddenCount ?? 0,
279
+ transactionCount: summary?.transactionCount ?? 0,
280
+ cashflow: {
281
+ moneyIn: round2(num(summary?.cashflow?.moneyIn)),
282
+ moneyOut: round2(num(summary?.cashflow?.moneyOut)),
283
+ net: round2(num(summary?.cashflow?.net)),
284
+ transactions: summary?.cashflow?.transactions ?? 0,
285
+ },
286
+ byKind: summary?.byKind || [],
287
+ byInstitution: (summary?.byInstitution || []).slice().sort((a, b) => (b.assets + b.liabilities) - (a.assets + a.liabilities)),
288
+ topCategories: summary?.topCategories || [],
289
+ newestTransaction: summary?.newestTransaction || null,
290
+ accounts,
291
+ creditCards: accounts.filter((a) => (a.effective_kind || a.kind) === 'credit'),
292
+ connections: raw.connections?.connections || [],
293
+ plaidEnabled: Boolean(raw.connections?.plaidEnabled),
294
+ ledger: raw.transactions?.rows || [],
295
+ ledgerTotal: raw.transactions?.total ?? 0,
296
+ };
297
+
298
+ const refundsUsd = round2(card.refundedUsd + escrow.refundedUsd);
299
+ const stats = raw.stats || null;
300
+
301
+ // Debt against income. Computed server-side, because it needs six months of
302
+ // rows and the ledger this snapshot carries is one page of the window.
303
+ // Absent when the summary route failed, and the screen says so rather than
304
+ // drawing zeros.
305
+ const position = summary?.position || null;
306
+
307
+ return {
308
+ generatedAt: now.toISOString(),
309
+ windowDays: days,
310
+ plan: stats?.plan || null,
311
+ businesses: stats?.businesses || [],
312
+ earnings: {
313
+ grossVolumeUsd: round2(grossVolumeUsd),
314
+ cryptoVolumeUsd: analytics ? num(analytics.crypto?.total_volume_usd) : crypto.volumeUsd,
315
+ cardVolumeUsd: analytics ? num(analytics.card?.total_volume_usd) : card.volumeUsd,
316
+ commissionUsd: round2(commissionUsd),
317
+ stripeFeesUsd: card.stripeFeesUsd,
318
+ refundsUsd,
319
+ netUsd: round2(grossVolumeUsd - commissionUsd - card.stripeFeesUsd - refundsUsd),
320
+ transactions: analytics ? num(analytics.combined?.successful_transactions) : crypto.successful + card.successful,
321
+ failed: analytics ? num(analytics.combined?.failed_transactions) : crypto.failed + card.failed,
322
+ failureRate: analytics ? num(analytics.combined?.failure_rate) : 0,
323
+ },
324
+ series,
325
+ crypto,
326
+ card,
327
+ escrow,
328
+ invoices: { totals: invoiceTotals, counts: invoiceCounts, rows: invoices },
329
+ payout,
330
+ bank,
331
+ position,
332
+ recent: {
333
+ payments: payments.slice(0, 50),
334
+ cards: cardTx.slice(0, 50),
335
+ escrows: escrows.slice(0, 50),
336
+ },
337
+ errors,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Fetch everything and build a snapshot. Routes fail independently: a 500
343
+ * from one of them lands in `snapshot.errors[name]` and its section is empty.
344
+ */
345
+ export async function collectFinanceSnapshot(client, { days = 30, limit = 100, businessId } = {}) {
346
+ const since = Number.isFinite(days) && days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : undefined;
347
+ const sources = {
348
+ summary: () => getFinanceSummary(client, { days }),
349
+ accounts: () => listFinanceAccounts(client),
350
+ transactions: () => listFinanceTransactions(client, { limit, startDate: since }),
351
+ connections: () => listFinanceConnections(client),
352
+ stats: () => getDashboardStats(client, { businessId }),
353
+ analytics: () => getFinanceAnalytics(client, { period: periodForDays(days), businessId }),
354
+ payments: () => listCryptoPayments(client, { limit, businessId, dateFrom: since }),
355
+ cardTransactions: () => listCardTransactions(client, { limit, businessId, dateFrom: since }),
356
+ // Raw route on purpose: the SDK's listEscrows camel-cases fields and the
357
+ // snapshot reads the API's snake_case shape like every other source.
358
+ escrows: () => client.request(`/escrow${query({ limit })}`),
359
+ invoices: () => client.request('/invoices'),
360
+ payouts: () => listCardPayouts(client, { limit }),
361
+ };
362
+
363
+ const names = Object.keys(sources);
364
+ const settled = await Promise.allSettled(names.map((name) => sources[name]()));
365
+ const raw = { errors: {} };
366
+ settled.forEach((result, i) => {
367
+ if (result.status === 'fulfilled') raw[names[i]] = result.value;
368
+ else raw.errors[names[i]] = result.reason?.message || String(result.reason);
369
+ });
370
+
371
+ // One failing source is a hole in the picture; every source failing means
372
+ // the API or the session is gone, and a page of zeros would be a lie.
373
+ if (Object.keys(raw.errors).length === names.length) {
374
+ const first = raw.errors[names[0]];
375
+ throw new Error(`Could not load finances: ${first}`);
376
+ }
377
+
378
+ return buildFinanceSnapshot(raw, { days });
379
+ }
380
+
381
+ /**
382
+ * Subscribe to the merchant's live payment stream (server-sent events).
383
+ *
384
+ * Takes the API base URL and the merchant session token explicitly (the
385
+ * client keeps its key private). `onEvent` receives parsed events (`connected`, `heartbeat`, `payment_*`).
386
+ * Returns a function that closes the stream. Reconnects itself with backoff
387
+ * until closed, so a dropped connection is a gap, not the end.
388
+ */
389
+ export function subscribeToPayments({ baseUrl, token, onEvent, onStatus, businessId } = {}) {
390
+ if (!baseUrl || !token) throw new Error('subscribeToPayments needs baseUrl and token');
391
+ baseUrl = baseUrl.replace(/\/$/, '');
392
+ let closed = false;
393
+ let controller = null;
394
+ let attempt = 0;
395
+
396
+ const status = (s, detail) => { try { onStatus?.(s, detail); } catch { /* listener error */ } };
397
+
398
+ async function connect() {
399
+ while (!closed) {
400
+ controller = new AbortController();
401
+ try {
402
+ status('connecting');
403
+ const res = await fetch(`${baseUrl}/realtime/payments${query({ businessId })}`, {
404
+ headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
405
+ signal: controller.signal,
406
+ });
407
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
408
+ attempt = 0;
409
+ status('connected');
410
+ const reader = res.body.getReader();
411
+ const decoder = new TextDecoder();
412
+ let buffer = '';
413
+ for (;;) {
414
+ const { value, done } = await reader.read();
415
+ if (done) break;
416
+ buffer += decoder.decode(value, { stream: true });
417
+ let idx;
418
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
419
+ const chunk = buffer.slice(0, idx);
420
+ buffer = buffer.slice(idx + 2);
421
+ const data = chunk.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
422
+ if (!data) continue;
423
+ try { onEvent?.(JSON.parse(data)); } catch { /* not JSON */ }
424
+ }
425
+ }
426
+ if (!closed) status('disconnected');
427
+ } catch (err) {
428
+ if (closed) return;
429
+ status('error', err?.message || String(err));
430
+ }
431
+ if (closed) return;
432
+ attempt += 1;
433
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(attempt, 5));
434
+ await new Promise((r) => setTimeout(r, delay));
435
+ }
436
+ }
437
+
438
+ void connect();
439
+
440
+ return () => {
441
+ closed = true;
442
+ try { controller?.abort(); } catch { /* already gone */ }
443
+ status('closed');
444
+ };
445
+ }
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,5 @@ export type {
146
147
 
147
148
  import { CoinPayClient } from './client.js';
148
149
  export default CoinPayClient;
150
+
151
+ export * from './finances.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,
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
 
package/src/swap.js CHANGED
@@ -238,11 +238,42 @@ export class SwapClient {
238
238
 
239
239
  const startTime = Date.now();
240
240
  let lastStatus = null;
241
-
241
+
242
+ // F3-L5-02: a failed poll must not end the watch.
243
+ //
244
+ // `getSwapStatus` was awaited bare inside this loop, so one 502, 504 or
245
+ // socket timeout from the provider threw straight out of `waitForSwap`.
246
+ // The swap itself is unaffected — it is still in flight at the provider —
247
+ // but the caller has lost all visibility of it, and the natural reaction to
248
+ // "the call threw" is to assume the swap failed. A transient blip on a
249
+ // poll that runs for up to an hour is expected, not exceptional.
250
+ //
251
+ // Consecutive failures are what matter: an isolated blip is retried on the
252
+ // next tick, while a provider that is genuinely gone stops the loop rather
253
+ // than spinning silently until the timeout.
254
+ const maxConsecutiveErrors = options.maxConsecutiveErrors ?? 5;
255
+ let consecutiveErrors = 0;
256
+
242
257
  while (Date.now() - startTime < timeout) {
243
- const result = await this.getSwapStatus(swapId);
258
+ let result;
259
+ try {
260
+ result = await this.getSwapStatus(swapId);
261
+ consecutiveErrors = 0;
262
+ } catch (err) {
263
+ consecutiveErrors++;
264
+ if (consecutiveErrors >= maxConsecutiveErrors) {
265
+ throw new Error(
266
+ `Lost contact with the swap provider after ${consecutiveErrors} consecutive failures ` +
267
+ `while tracking ${swapId}. The swap may still be in progress — check its status directly. ` +
268
+ `Last error: ${err.message}`
269
+ );
270
+ }
271
+ await new Promise(resolve => setTimeout(resolve, interval));
272
+ continue;
273
+ }
274
+
244
275
  const currentStatus = result.swap?.status;
245
-
276
+
246
277
  // Notify on status change
247
278
  if (currentStatus !== lastStatus) {
248
279
  if (onStatusChange && lastStatus !== null) {
@@ -250,12 +281,12 @@ export class SwapClient {
250
281
  }
251
282
  lastStatus = currentStatus;
252
283
  }
253
-
284
+
254
285
  // Check if we've reached a target status
255
286
  if (targetStatuses.includes(currentStatus)) {
256
287
  return result;
257
288
  }
258
-
289
+
259
290
  // Wait before next poll
260
291
  await new Promise(resolve => setTimeout(resolve, interval));
261
292
  }