@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.
- package/CHANGELOG.md +39 -0
- package/README.md +128 -3
- package/bin/coinpay.js +919 -30
- package/package.json +24 -9
- package/src/card-payments.js +23 -14
- package/src/client.js +40 -2
- package/src/finances-tui.js +1005 -0
- package/src/finances.d.ts +333 -0
- package/src/finances.js +445 -0
- package/src/index.d.ts +3 -0
- package/src/index.js +35 -0
- package/src/invoices.d.ts +1 -0
- package/src/invoices.js +14 -1
- package/src/lightning.js +8 -3
- package/src/swap.js +36 -5
- package/src/taskmarket.js +329 -0
- package/src/wallet.js +54 -24
package/src/finances.js
ADDED
|
@@ -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 -
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
}
|