@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
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `coinpay finances` — a live terminal dashboard for the merchant's money.
|
|
3
|
+
*
|
|
4
|
+
* Built on @profullstack/hqtui. Seven screens: Overview, Bank & Cards, Ledger,
|
|
5
|
+
* Crypto, Cards, Invoices & Escrow, Debt & Income. Data comes from `collectFinanceSnapshot`
|
|
6
|
+
* on a timer, plus the payments server-sent-event stream for instant crypto
|
|
7
|
+
* payment notices in the Live panel. Bank syncs are a keypress (`s`), never
|
|
8
|
+
* automatic — the bank bridge allows about 24 pulls a day.
|
|
9
|
+
*
|
|
10
|
+
* hqtui is imported lazily so the rest of the CLI keeps working on a Node
|
|
11
|
+
* that cannot load it (it needs Node 22.6+); `coinpay finances summary` is
|
|
12
|
+
* the plain-text fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { collectFinanceSnapshot, subscribeToPayments, syncFinances } from './finances.js';
|
|
16
|
+
|
|
17
|
+
const TABS = ['Overview', 'Bank & Cards', 'Ledger', 'Crypto', 'Cards', 'Invoices & Escrow', 'Debt & Income'];
|
|
18
|
+
const WINDOWS = [7, 30, 90, 365];
|
|
19
|
+
const LIVE_MAX = 200;
|
|
20
|
+
|
|
21
|
+
// ── Formatting ──
|
|
22
|
+
|
|
23
|
+
const fmtCache = new Map();
|
|
24
|
+
export function money(value, currency = 'USD', { compact = false } = {}) {
|
|
25
|
+
const n = Number(value) || 0;
|
|
26
|
+
const key = `${currency}:${compact}`;
|
|
27
|
+
let fmt = fmtCache.get(key);
|
|
28
|
+
if (!fmt) {
|
|
29
|
+
try {
|
|
30
|
+
fmt = new Intl.NumberFormat('en-US', {
|
|
31
|
+
style: 'currency',
|
|
32
|
+
currency,
|
|
33
|
+
maximumFractionDigits: compact ? 0 : 2,
|
|
34
|
+
minimumFractionDigits: compact ? 0 : 2,
|
|
35
|
+
notation: compact ? 'compact' : 'standard',
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
|
39
|
+
}
|
|
40
|
+
fmtCache.set(key, fmt);
|
|
41
|
+
}
|
|
42
|
+
return fmt.format(n);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A ratio as a percentage. Null stays visibly absent rather than becoming 0%. */
|
|
46
|
+
export function pct(value, digits = 0) {
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
if (value === null || value === undefined || !Number.isFinite(n)) return '—';
|
|
49
|
+
return `${(n * 100).toFixed(digits)}%`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function shortDate(value) {
|
|
53
|
+
if (!value) return '—';
|
|
54
|
+
const d = new Date(value);
|
|
55
|
+
if (Number.isNaN(d.getTime())) return String(value).slice(0, 10);
|
|
56
|
+
return d.toISOString().slice(0, 10);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function shortDateTime(value) {
|
|
60
|
+
if (!value) return '—';
|
|
61
|
+
const d = new Date(value);
|
|
62
|
+
if (Number.isNaN(d.getTime())) return String(value).slice(0, 16);
|
|
63
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
64
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ago(value) {
|
|
68
|
+
if (!value) return 'never';
|
|
69
|
+
const ms = Date.now() - new Date(value).getTime();
|
|
70
|
+
if (!Number.isFinite(ms) || ms < 0) return 'just now';
|
|
71
|
+
const s = Math.floor(ms / 1000);
|
|
72
|
+
if (s < 60) return `${s}s ago`;
|
|
73
|
+
const m = Math.floor(s / 60);
|
|
74
|
+
if (m < 60) return `${m}m ago`;
|
|
75
|
+
const h = Math.floor(m / 60);
|
|
76
|
+
if (h < 48) return `${h}h ago`;
|
|
77
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clock() {
|
|
81
|
+
const d = new Date();
|
|
82
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
83
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function shortId(value, n = 8) {
|
|
87
|
+
const s = String(value || '');
|
|
88
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function num(v) {
|
|
92
|
+
const n = typeof v === 'number' ? v : parseFloat(v ?? '');
|
|
93
|
+
return Number.isFinite(n) ? n : 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── State ──
|
|
97
|
+
|
|
98
|
+
function createState(days) {
|
|
99
|
+
return {
|
|
100
|
+
tab: 0,
|
|
101
|
+
days,
|
|
102
|
+
snapshot: null,
|
|
103
|
+
loading: false,
|
|
104
|
+
lastRefresh: null,
|
|
105
|
+
error: null,
|
|
106
|
+
live: [],
|
|
107
|
+
liveStatus: 'connecting',
|
|
108
|
+
syncing: false,
|
|
109
|
+
notice: null,
|
|
110
|
+
paused: false,
|
|
111
|
+
showHelp: false,
|
|
112
|
+
panes: {},
|
|
113
|
+
seen: { payments: new Set(), cards: new Set() },
|
|
114
|
+
primed: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pane(state, name, total) {
|
|
119
|
+
let p = state.panes[name];
|
|
120
|
+
if (!p) {
|
|
121
|
+
p = { selected: 0, offset: 0, total: 0 };
|
|
122
|
+
state.panes[name] = p;
|
|
123
|
+
}
|
|
124
|
+
p.total = total;
|
|
125
|
+
const max = Math.max(0, total - 1);
|
|
126
|
+
p.selected = Math.min(p.selected, max);
|
|
127
|
+
p.offset = Math.min(p.offset, max);
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scrollPane(p, delta, rows = 3) {
|
|
132
|
+
const max = Math.max(0, p.total - 1);
|
|
133
|
+
p.offset = Math.max(0, Math.min(p.offset + delta * rows, max));
|
|
134
|
+
p.selected = Math.max(p.offset, Math.min(p.selected, max));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function moveSelection(p, delta) {
|
|
138
|
+
const max = Math.max(0, p.total - 1);
|
|
139
|
+
p.selected = Math.max(0, Math.min(p.selected + delta, max));
|
|
140
|
+
if (p.selected < p.offset) p.offset = p.selected;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The table each screen scrolls with the keyboard.
|
|
144
|
+
const TAB_PANE = ['live', 'accounts', 'ledger', 'payments', 'cards', 'invoices', 'debts'];
|
|
145
|
+
|
|
146
|
+
function pushLive(state, entry) {
|
|
147
|
+
state.live.push({ time: clock(), ...entry });
|
|
148
|
+
if (state.live.length > LIVE_MAX) state.live.splice(0, state.live.length - LIVE_MAX);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Record which rows we have already shown so the next refresh can announce only new ones. */
|
|
152
|
+
function noteNewRows(state, snapshot) {
|
|
153
|
+
const payments = snapshot.recent.payments || [];
|
|
154
|
+
const cards = snapshot.recent.cards || [];
|
|
155
|
+
if (state.primed) {
|
|
156
|
+
for (const p of payments) {
|
|
157
|
+
if (state.seen.payments.has(p.id)) continue;
|
|
158
|
+
pushLive(state, {
|
|
159
|
+
level: 'PAY',
|
|
160
|
+
message: `crypto ${p.status} ${money(p.amount_usd)} ${p.currency || ''} ${p.business_name ? `· ${p.business_name}` : ''}`.trim(),
|
|
161
|
+
meta: shortId(p.id),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
for (const c of cards) {
|
|
165
|
+
if (state.seen.cards.has(c.id)) continue;
|
|
166
|
+
pushLive(state, {
|
|
167
|
+
level: 'CARD',
|
|
168
|
+
message: `card ${c.status} ${money(num(c.amount_cents) / 100, (c.currency || 'usd').toUpperCase())} ${c.business_name ? `· ${c.business_name}` : ''} ${c.customer_email ? `· ${c.customer_email}` : ''}`.trim(),
|
|
169
|
+
meta: shortId(c.id),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const p of payments) state.seen.payments.add(p.id);
|
|
174
|
+
for (const c of cards) state.seen.cards.add(c.id);
|
|
175
|
+
state.primed = true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Screens ──
|
|
179
|
+
|
|
180
|
+
function statusColor(theme, status) {
|
|
181
|
+
const s = String(status || '').toLowerCase();
|
|
182
|
+
if (['completed', 'succeeded', 'forwarded', 'confirmed', 'paid', 'released', 'settled', 'ok', 'active'].includes(s)) return theme.success;
|
|
183
|
+
if (['pending', 'detected', 'confirming', 'forwarding', 'sent', 'funded', 'draft', 'in_transit', 'partial'].includes(s)) return theme.warning;
|
|
184
|
+
if (['failed', 'expired', 'refunded', 'partially_refunded', 'disputed', 'cancelled', 'canceled', 'overdue', 'error', 'forwarding_failed'].includes(s)) return theme.danger;
|
|
185
|
+
return theme.muted;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function signed(theme, value) {
|
|
189
|
+
return num(value) >= 0 ? theme.success : theme.danger;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function emptyPanel(ui, theme, title, message) {
|
|
193
|
+
ui.panel({ title }, (p) => p.text(message, { fg: theme.muted }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function overviewScreen(ui, state, theme) {
|
|
197
|
+
const s = state.snapshot;
|
|
198
|
+
const e = s.earnings;
|
|
199
|
+
const b = s.bank;
|
|
200
|
+
const cur = b.currency || 'USD';
|
|
201
|
+
const win = `${state.days}d`;
|
|
202
|
+
|
|
203
|
+
ui.grid({ columns: ['1fr', '1fr', '1fr'], rows: [11, '1fr', 12], gap: 1 }, (grid) => {
|
|
204
|
+
grid.panel({ title: `Earnings · ${win}`, subtitle: s.plan ? `${s.plan.commission_percent} plan` : undefined }, (p) => {
|
|
205
|
+
p.keyValues([
|
|
206
|
+
{ label: 'Gross volume', value: money(e.grossVolumeUsd), color: theme.primary },
|
|
207
|
+
{ label: ' crypto', value: money(e.cryptoVolumeUsd) },
|
|
208
|
+
{ label: ' cards', value: money(e.cardVolumeUsd) },
|
|
209
|
+
{ label: 'Commission paid', value: `-${money(e.commissionUsd)}`, color: theme.warning },
|
|
210
|
+
{ label: 'Card processor fees', value: `-${money(e.stripeFeesUsd)}`, color: theme.warning },
|
|
211
|
+
{ label: 'Refunds', value: `-${money(e.refundsUsd)}`, color: e.refundsUsd > 0 ? theme.danger : theme.muted },
|
|
212
|
+
{ label: 'Net earnings', value: money(e.netUsd), color: signed(theme, e.netUsd) },
|
|
213
|
+
{ label: 'Paid transactions', value: `${e.transactions} (${e.failed} failed, ${e.failureRate}%)` },
|
|
214
|
+
], { labelWidth: 20 });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
grid.panel({ title: 'Bank & cards', subtitle: b.connections.length ? `${b.accountCount} accounts` : 'nothing linked' }, (p) => {
|
|
218
|
+
if (!b.connections.length) {
|
|
219
|
+
p.text('Link a bank at coinpayportal.com/finances', { fg: theme.muted });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
p.keyValues([
|
|
223
|
+
{ label: 'Cash & assets', value: money(b.assets, cur), color: theme.success },
|
|
224
|
+
{ label: 'Cards & loans owed', value: money(b.liabilities, cur), color: theme.danger },
|
|
225
|
+
{ label: 'Net position', value: money(b.net, cur), color: signed(theme, b.net) },
|
|
226
|
+
{ label: `Cash in · ${win}`, value: money(b.cashflow.moneyIn, cur), color: theme.success },
|
|
227
|
+
{ label: `Cash out · ${win}`, value: money(b.cashflow.moneyOut, cur), color: theme.danger },
|
|
228
|
+
{ label: 'Cashflow net', value: money(b.cashflow.net, cur), color: signed(theme, b.cashflow.net) },
|
|
229
|
+
{ label: 'Credit cards', value: `${b.creditCards.length} owing ${money(b.creditCards.reduce((t, a) => t + Math.abs(num(a.display_balance ?? a.balance)), 0), cur)}` },
|
|
230
|
+
{ label: 'Last bank sync', value: ago(b.connections[0]?.last_synced_at), color: statusColor(theme, b.connections[0]?.last_sync_status) },
|
|
231
|
+
], { labelWidth: 20 });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
grid.panel({ title: 'Pipeline' }, (p) => {
|
|
235
|
+
const inv = s.invoices;
|
|
236
|
+
const esc = s.escrow;
|
|
237
|
+
p.keyValues([
|
|
238
|
+
{ label: 'Invoices outstanding', value: `${money(inv.totals.outstanding)} (${inv.counts.outstanding})`, color: theme.warning },
|
|
239
|
+
{ label: 'Invoices overdue', value: `${money(inv.totals.overdue)} (${inv.counts.overdue})`, color: inv.counts.overdue ? theme.danger : theme.muted },
|
|
240
|
+
{ label: `Invoices paid · ${win}`, value: `${money(inv.totals.paid)} (${inv.counts.paid})`, color: theme.success },
|
|
241
|
+
{ label: 'Escrow held', value: `${money(esc.heldUsd)} (${esc.held})`, color: theme.info },
|
|
242
|
+
{ label: `Escrow released · ${win}`, value: `${money(esc.releasedUsd)} (${esc.released})` },
|
|
243
|
+
{ label: `Escrow refunded · ${win}`, value: `${money(esc.refundedUsd)} (${esc.refunded})`, color: esc.refunded ? theme.danger : theme.muted },
|
|
244
|
+
{ label: 'Payouts pending', value: money(s.payout.pendingUsd) },
|
|
245
|
+
{ label: `Payouts paid · ${win}`, value: money(s.payout.paidUsd), color: theme.success },
|
|
246
|
+
], { labelWidth: 22 });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
grid.panel({ title: `Volume by day · ${win}`, colSpan: 3, subtitle: 'volume vs commission' }, (p) => {
|
|
250
|
+
const pts = s.series;
|
|
251
|
+
if (!pts.length) {
|
|
252
|
+
p.text('No volume in this window.', { fg: theme.muted });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const step = Math.max(1, Math.ceil(pts.length / 8));
|
|
256
|
+
p.multiGraph(
|
|
257
|
+
[
|
|
258
|
+
{ values: pts.map((x) => x.volumeUsd), color: theme.primary, label: 'volume', fill: true },
|
|
259
|
+
{ values: pts.map((x) => x.commissionUsd), color: theme.warning, label: 'commission' },
|
|
260
|
+
],
|
|
261
|
+
{
|
|
262
|
+
min: 0,
|
|
263
|
+
axis: true,
|
|
264
|
+
axisFormat: (v) => money(v, 'USD', { compact: true }),
|
|
265
|
+
timeAxis: pts.map((x, i) => (i % step === 0 ? x.label.slice(5) : '')),
|
|
266
|
+
legend: true,
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
grid.panel({ title: 'Live', colSpan: 2, subtitle: state.liveStatus, subtitleColor: state.liveStatus === 'connected' ? theme.success : theme.warning }, (p) => {
|
|
272
|
+
const live = pane(state, 'live', state.live.length);
|
|
273
|
+
p.log({
|
|
274
|
+
entries: state.live.map((l) => ({ time: l.time, level: l.level, message: l.message, meta: l.meta })),
|
|
275
|
+
fromEnd: live.offset,
|
|
276
|
+
scrollbar: true,
|
|
277
|
+
levelColors: { PAY: theme.success, CARD: theme.info, SYNC: theme.warning, ERR: theme.danger, INFO: theme.muted, SSE: theme.muted },
|
|
278
|
+
onScroll: (delta) => scrollPane(live, -delta),
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
grid.panel({ title: 'By rail' }, (p) => {
|
|
283
|
+
const items = [{ label: 'cards', value: e.cardVolumeUsd, text: money(e.cardVolumeUsd, 'USD', { compact: true }) }];
|
|
284
|
+
for (const [chain, usd] of Object.entries(s.crypto.byChain).sort((a, b) => b[1] - a[1]).slice(0, 8)) {
|
|
285
|
+
items.push({ label: chain, value: usd, text: money(usd, 'USD', { compact: true }) });
|
|
286
|
+
}
|
|
287
|
+
const max = Math.max(1, ...items.map((i) => i.value));
|
|
288
|
+
p.meters(items.map((i) => ({ ...i, max })), { labelWidth: 9, valueWidth: 7 });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function bankScreen(ui, state, theme) {
|
|
294
|
+
const b = state.snapshot.bank;
|
|
295
|
+
const cur = b.currency || 'USD';
|
|
296
|
+
if (!b.connections.length) {
|
|
297
|
+
emptyPanel(ui, theme, 'Bank & cards', 'No bank linked yet. Connect one at coinpayportal.com/finances (SimpleFIN or Plaid), then press s to sync.');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
ui.grid({ columns: ['3fr', '2fr'], gap: 1 }, (grid) => {
|
|
301
|
+
grid.panel({ title: `Accounts (${b.accounts.length})`, footer: 'j/k scroll · s sync' }, (p) => {
|
|
302
|
+
const accounts = pane(state, 'accounts', b.accounts.length);
|
|
303
|
+
p.table({
|
|
304
|
+
rows: b.accounts,
|
|
305
|
+
selected: accounts.selected,
|
|
306
|
+
offset: accounts.offset,
|
|
307
|
+
followSelection: true,
|
|
308
|
+
scrollbar: true,
|
|
309
|
+
onScroll: (delta) => scrollPane(accounts, delta),
|
|
310
|
+
onSelectRow: (row) => { accounts.selected = accounts.offset + row; },
|
|
311
|
+
columns: [
|
|
312
|
+
{ key: 'org_name', title: 'Institution', width: 22, render: (r) => r.org_name || '—', color: theme.muted },
|
|
313
|
+
{ key: 'name', title: 'Account', min: 14, max: 36 },
|
|
314
|
+
{ key: 'effective_kind', title: 'Kind', width: 10, color: (r) => (r.is_liability ? theme.danger : theme.success) },
|
|
315
|
+
{ key: 'display_balance', title: 'Balance', width: 13, align: 'right', render: (r) => money(r.display_balance ?? r.balance ?? 0, r.currency || cur), color: (r) => (r.is_liability ? theme.danger : theme.success) },
|
|
316
|
+
{ key: 'available_balance', title: 'Available', width: 12, align: 'right', render: (r) => (r.available_balance == null ? '—' : money(r.available_balance, r.currency || cur)), color: theme.muted },
|
|
317
|
+
{ key: 'balance_date', title: 'As of', width: 10, render: (r) => shortDate(r.balance_date), color: theme.muted },
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
323
|
+
col.panel({ title: 'Owed by institution', size: Math.min(14, b.byInstitution.length + 3) }, (p) => {
|
|
324
|
+
const rows = b.byInstitution.filter((i) => i.liabilities > 0 || i.assets > 0);
|
|
325
|
+
const max = Math.max(1, ...rows.map((i) => Math.max(i.liabilities, i.assets)));
|
|
326
|
+
p.meters(
|
|
327
|
+
rows.slice(0, 10).map((i) => ({
|
|
328
|
+
label: i.org.slice(0, 14),
|
|
329
|
+
value: i.liabilities > 0 ? i.liabilities : i.assets,
|
|
330
|
+
max,
|
|
331
|
+
color: i.liabilities > 0 ? theme.danger : theme.success,
|
|
332
|
+
text: money(i.liabilities > 0 ? i.liabilities : i.assets, cur, { compact: true }),
|
|
333
|
+
})),
|
|
334
|
+
{ labelWidth: 15, valueWidth: 7 },
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
col.panel({ title: 'Position' }, (p) => {
|
|
338
|
+
p.keyValues([
|
|
339
|
+
{ label: 'Assets', value: money(b.assets, cur), color: theme.success },
|
|
340
|
+
{ label: 'Liabilities', value: money(b.liabilities, cur), color: theme.danger },
|
|
341
|
+
{ label: 'Net', value: money(b.net, cur), color: signed(theme, b.net) },
|
|
342
|
+
...b.byKind.map((k) => ({ label: ` ${k.kind} (${k.accounts})`, value: money(k.total, k.currency || cur), color: theme.muted })),
|
|
343
|
+
], { labelWidth: 16 });
|
|
344
|
+
});
|
|
345
|
+
col.panel({ title: 'Connections' }, (p) => {
|
|
346
|
+
p.keyValues(
|
|
347
|
+
b.connections.flatMap((c) => [
|
|
348
|
+
{ label: c.provider, value: `${c.label || c.id.slice(0, 8)} · ${c.is_active ? 'active' : 'inactive'}`, color: c.is_active ? theme.foreground : theme.muted },
|
|
349
|
+
{ label: ' last sync', value: `${ago(c.last_synced_at)} · ${c.last_sync_status || '—'} · ${c.last_sync_accounts ?? 0} acct / ${c.last_sync_transactions ?? 0} tx`, color: statusColor(theme, c.last_sync_status) },
|
|
350
|
+
...(c.last_sync_error ? [{ label: ' note', value: String(c.last_sync_error).slice(0, 60), color: theme.warning }] : []),
|
|
351
|
+
]),
|
|
352
|
+
{ labelWidth: 12 },
|
|
353
|
+
);
|
|
354
|
+
if (state.syncing) p.text('Syncing with the bank bridge…', { fg: theme.warning });
|
|
355
|
+
else if (state.notice) p.text(state.notice, { fg: theme.muted });
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ledgerScreen(ui, state, theme) {
|
|
362
|
+
const b = state.snapshot.bank;
|
|
363
|
+
const cur = b.currency || 'USD';
|
|
364
|
+
if (!b.ledger.length) {
|
|
365
|
+
emptyPanel(ui, theme, 'Ledger', b.connections.length ? 'No transactions in this window. Press s to sync.' : 'No bank linked yet.');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
ui.grid({ columns: ['3fr', '1fr'], gap: 1 }, (grid) => {
|
|
369
|
+
grid.panel({ title: `Ledger · ${state.days}d`, subtitle: `${b.ledger.length} of ${b.ledgerTotal}`, footer: 'newest first' }, (p) => {
|
|
370
|
+
const ledger = pane(state, 'ledger', b.ledger.length);
|
|
371
|
+
p.table({
|
|
372
|
+
rows: b.ledger,
|
|
373
|
+
selected: ledger.selected,
|
|
374
|
+
offset: ledger.offset,
|
|
375
|
+
followSelection: true,
|
|
376
|
+
scrollbar: true,
|
|
377
|
+
zebra: true,
|
|
378
|
+
onScroll: (delta) => scrollPane(ledger, delta),
|
|
379
|
+
onSelectRow: (row) => { ledger.selected = ledger.offset + row; },
|
|
380
|
+
columns: [
|
|
381
|
+
{ key: 'posted', title: 'Date', width: 10, render: (r) => shortDate(r.transacted_at || r.posted), color: theme.muted },
|
|
382
|
+
{ key: 'account_name', title: 'Account', width: 26, render: (r) => `${r.org_name ? r.org_name.split(' ')[0] + ' ' : ''}${r.account_name}` },
|
|
383
|
+
{ key: 'payee', title: 'Payee / description', min: 14, max: 40, render: (r) => r.payee || r.description || r.memo || '—' },
|
|
384
|
+
{ key: 'category', title: 'Category', width: 14, render: (r) => r.category || '—', color: theme.muted },
|
|
385
|
+
{ key: 'pending', title: '', width: 1, render: (r) => (r.pending ? '•' : ''), color: theme.warning },
|
|
386
|
+
{ key: 'amount', title: 'Amount', width: 12, align: 'right', render: (r) => money(r.amount, r.currency || cur), color: (r) => signed(theme, r.amount) },
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
391
|
+
col.panel({ title: `Cashflow · ${state.days}d`, size: 7 }, (p) => {
|
|
392
|
+
p.keyValues([
|
|
393
|
+
{ label: 'In', value: money(b.cashflow.moneyIn, cur), color: theme.success },
|
|
394
|
+
{ label: 'Out', value: money(b.cashflow.moneyOut, cur), color: theme.danger },
|
|
395
|
+
{ label: 'Net', value: money(b.cashflow.net, cur), color: signed(theme, b.cashflow.net) },
|
|
396
|
+
{ label: 'Transactions', value: String(b.cashflow.transactions) },
|
|
397
|
+
], { labelWidth: 13 });
|
|
398
|
+
});
|
|
399
|
+
col.panel({ title: 'Spend by category' }, (p) => {
|
|
400
|
+
const cats = b.topCategories.filter((c) => c.spent > 0).slice(0, 14);
|
|
401
|
+
const max = Math.max(1, ...cats.map((c) => c.spent));
|
|
402
|
+
p.meters(
|
|
403
|
+
cats.map((c) => ({ label: (c.category || 'uncategorised').slice(0, 14), value: c.spent, max, text: money(c.spent, cur, { compact: true }) })),
|
|
404
|
+
{ labelWidth: 15, valueWidth: 7, heat: true },
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function cryptoScreen(ui, state, theme) {
|
|
412
|
+
const s = state.snapshot;
|
|
413
|
+
const rows = s.recent.payments;
|
|
414
|
+
ui.grid({ columns: ['3fr', '1fr'], gap: 1 }, (grid) => {
|
|
415
|
+
grid.panel({ title: `Crypto payments · ${state.days}d`, subtitle: s.crypto.partial ? 'latest page' : undefined }, (p) => {
|
|
416
|
+
if (!rows.length) {
|
|
417
|
+
p.text('No crypto payments in this window.', { fg: theme.muted });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const payments = pane(state, 'payments', rows.length);
|
|
421
|
+
p.table({
|
|
422
|
+
rows,
|
|
423
|
+
selected: payments.selected,
|
|
424
|
+
offset: payments.offset,
|
|
425
|
+
followSelection: true,
|
|
426
|
+
scrollbar: true,
|
|
427
|
+
onScroll: (delta) => scrollPane(payments, delta),
|
|
428
|
+
onSelectRow: (row) => { payments.selected = payments.offset + row; },
|
|
429
|
+
columns: [
|
|
430
|
+
{ key: 'created_at', title: 'When', width: 11, render: (r) => shortDateTime(r.created_at), color: theme.muted },
|
|
431
|
+
{ key: 'business_name', title: 'Business', min: 10, max: 28, render: (r) => r.business_name || shortId(r.business_id) },
|
|
432
|
+
{ key: 'currency', title: 'Chain', width: 9 },
|
|
433
|
+
{ key: 'amount_usd', title: 'USD', width: 10, align: 'right', render: (r) => money(r.amount_usd) },
|
|
434
|
+
{ key: 'amount_crypto', title: 'Crypto', width: 13, align: 'right', render: (r) => String(num(r.amount_crypto).toFixed(6)), color: theme.muted },
|
|
435
|
+
{ key: 'fee_amount', title: 'Fee', width: 8, align: 'right', render: (r) => (r.fee_amount ? money((num(r.fee_amount) / Math.max(num(r.amount_crypto), 1e-12)) * num(r.amount_usd)) : '—'), color: theme.warning },
|
|
436
|
+
{ key: 'status', title: 'Status', width: 11, color: (r) => statusColor(theme, r.status) },
|
|
437
|
+
{ key: 'tx_hash', title: 'Tx', width: 10, render: (r) => shortId(r.forward_tx_hash || r.tx_hash || '', 9), color: theme.muted },
|
|
438
|
+
],
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
442
|
+
col.panel({ title: 'Totals', size: 9 }, (p) => {
|
|
443
|
+
p.keyValues([
|
|
444
|
+
{ label: 'Volume', value: money(s.earnings.cryptoVolumeUsd), color: theme.primary },
|
|
445
|
+
{ label: 'Commission', value: money(s.crypto.feesUsd), color: theme.warning },
|
|
446
|
+
{ label: 'Paid', value: String(s.crypto.successful), color: theme.success },
|
|
447
|
+
{ label: 'Pending', value: String(s.crypto.pending), color: theme.warning },
|
|
448
|
+
{ label: 'Failed/expired', value: String(s.crypto.failed), color: theme.danger },
|
|
449
|
+
{ label: 'Rate', value: s.plan ? s.plan.commission_percent : '—' },
|
|
450
|
+
], { labelWidth: 15 });
|
|
451
|
+
});
|
|
452
|
+
col.panel({ title: 'By chain' }, (p) => {
|
|
453
|
+
const entries = Object.entries(s.crypto.byChain).sort((a, b) => b[1] - a[1]);
|
|
454
|
+
if (!entries.length) { p.text('—', { fg: theme.muted }); return; }
|
|
455
|
+
const max = Math.max(1, ...entries.map((e) => e[1]));
|
|
456
|
+
p.meters(entries.map(([chain, usd]) => ({ label: chain, value: usd, max, text: money(usd, 'USD', { compact: true }) })), { labelWidth: 10, valueWidth: 7 });
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function cardsScreen(ui, state, theme) {
|
|
463
|
+
const s = state.snapshot;
|
|
464
|
+
const rows = s.recent.cards;
|
|
465
|
+
ui.grid({ columns: ['3fr', '1fr'], gap: 1 }, (grid) => {
|
|
466
|
+
grid.panel({ title: `Card payments · ${state.days}d`, subtitle: s.card.partial ? 'latest page' : undefined }, (p) => {
|
|
467
|
+
if (!rows.length) {
|
|
468
|
+
p.text('No card payments in this window.', { fg: theme.muted });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const cards = pane(state, 'cards', rows.length);
|
|
472
|
+
p.table({
|
|
473
|
+
rows,
|
|
474
|
+
selected: cards.selected,
|
|
475
|
+
offset: cards.offset,
|
|
476
|
+
followSelection: true,
|
|
477
|
+
scrollbar: true,
|
|
478
|
+
onScroll: (delta) => scrollPane(cards, delta),
|
|
479
|
+
onSelectRow: (row) => { cards.selected = cards.offset + row; },
|
|
480
|
+
columns: [
|
|
481
|
+
{ key: 'created_at', title: 'When', width: 11, render: (r) => shortDateTime(r.created_at), color: theme.muted },
|
|
482
|
+
{ key: 'business_name', title: 'Business', min: 10, max: 26, render: (r) => r.business_name || shortId(r.business_id) },
|
|
483
|
+
{ key: 'customer_email', title: 'Customer', min: 10, max: 26, render: (r) => r.customer_name || r.customer_email || '—', color: theme.muted },
|
|
484
|
+
{ key: 'amount_cents', title: 'Amount', width: 10, align: 'right', render: (r) => money(num(r.amount_cents) / 100, (r.currency || 'usd').toUpperCase()) },
|
|
485
|
+
{ key: 'platform_fee_amount', title: 'Commission', width: 10, align: 'right', render: (r) => money(num(r.platform_fee_amount) / 100), color: theme.warning },
|
|
486
|
+
{ key: 'stripe_fee_amount', title: 'Proc fee', width: 9, align: 'right', render: (r) => money(num(r.stripe_fee_amount) / 100), color: theme.muted },
|
|
487
|
+
{ key: 'net_to_merchant', title: 'Net', width: 10, align: 'right', render: (r) => money(num(r.net_to_merchant) / 100), color: theme.success },
|
|
488
|
+
{ key: 'status', title: 'Status', width: 11, color: (r) => statusColor(theme, r.status) },
|
|
489
|
+
],
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
493
|
+
col.panel({ title: 'Totals' }, (p) => {
|
|
494
|
+
p.keyValues([
|
|
495
|
+
{ label: 'Volume', value: money(s.earnings.cardVolumeUsd), color: theme.primary },
|
|
496
|
+
{ label: 'Commission', value: money(s.card.platformFeesUsd), color: theme.warning },
|
|
497
|
+
{ label: 'Processor fees', value: money(s.card.stripeFeesUsd), color: theme.muted },
|
|
498
|
+
{ label: 'Net to merchant', value: money(s.card.netUsd), color: theme.success },
|
|
499
|
+
{ label: 'Refunded', value: `${money(s.card.refundedUsd)} (${s.card.refunded})`, color: s.card.refunded ? theme.danger : theme.muted },
|
|
500
|
+
{ label: 'Succeeded', value: String(s.card.successful), color: theme.success },
|
|
501
|
+
{ label: 'Failed', value: String(s.card.failed), color: theme.danger },
|
|
502
|
+
{ label: 'Payouts paid', value: money(s.payout.paidUsd) },
|
|
503
|
+
{ label: 'Payouts pending', value: money(s.payout.pendingUsd) },
|
|
504
|
+
], { labelWidth: 16 });
|
|
505
|
+
if (s.errors.payouts) p.text(`payouts: ${s.errors.payouts}`, { fg: theme.warning });
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function invoicesScreen(ui, state, theme) {
|
|
512
|
+
const s = state.snapshot;
|
|
513
|
+
const invoices = s.invoices.rows;
|
|
514
|
+
const escrows = s.recent.escrows;
|
|
515
|
+
ui.panel({
|
|
516
|
+
title: `Invoices (${invoices.length})`,
|
|
517
|
+
subtitle: `outstanding ${money(s.invoices.totals.outstanding)} · overdue ${money(s.invoices.totals.overdue)} · paid ${money(s.invoices.totals.paid)}`,
|
|
518
|
+
size: '55%',
|
|
519
|
+
}, (p) => {
|
|
520
|
+
if (!invoices.length) { p.text('No invoices.', { fg: theme.muted }); return; }
|
|
521
|
+
const inv = pane(state, 'invoices', invoices.length);
|
|
522
|
+
p.table({
|
|
523
|
+
rows: invoices,
|
|
524
|
+
selected: inv.selected,
|
|
525
|
+
offset: inv.offset,
|
|
526
|
+
followSelection: true,
|
|
527
|
+
scrollbar: true,
|
|
528
|
+
onScroll: (delta) => scrollPane(inv, delta),
|
|
529
|
+
onSelectRow: (row) => { inv.selected = inv.offset + row; },
|
|
530
|
+
columns: [
|
|
531
|
+
{ key: 'invoice_number', title: 'No.', width: 9 },
|
|
532
|
+
{ key: 'clients', title: 'Client', min: 10, max: 28, render: (r) => r.clients?.name || r.clients?.email || '—' },
|
|
533
|
+
{ key: 'businesses', title: 'Business', min: 10, max: 26, render: (r) => r.businesses?.name || '—', color: theme.muted },
|
|
534
|
+
{ key: 'amount', title: 'Amount', width: 11, align: 'right', render: (r) => money(r.amount, r.currency || 'USD') },
|
|
535
|
+
{ key: 'status', title: 'Status', width: 10, color: (r) => statusColor(theme, r.status) },
|
|
536
|
+
{ key: 'due_date', title: 'Due', width: 10, render: (r) => shortDate(r.due_date), color: (r) => (r.due_date && r.status !== 'paid' && new Date(r.due_date) < new Date() ? theme.danger : theme.muted) },
|
|
537
|
+
{ key: 'paid_at', title: 'Paid', width: 10, render: (r) => shortDate(r.paid_at), color: theme.success },
|
|
538
|
+
{ key: 'crypto_currency', title: 'Settles in', width: 10, render: (r) => r.settlement_method || r.crypto_currency || '—', color: theme.muted },
|
|
539
|
+
],
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
ui.spacer(1);
|
|
543
|
+
ui.panel({
|
|
544
|
+
title: `Escrow (${escrows.length})`,
|
|
545
|
+
subtitle: `held ${money(s.escrow.heldUsd)} · released ${money(s.escrow.releasedUsd)} · refunded ${money(s.escrow.refundedUsd)}`,
|
|
546
|
+
}, (p) => {
|
|
547
|
+
if (!escrows.length) { p.text('No escrows.', { fg: theme.muted }); return; }
|
|
548
|
+
const esc = pane(state, 'escrows', escrows.length);
|
|
549
|
+
p.table({
|
|
550
|
+
rows: escrows,
|
|
551
|
+
selected: esc.selected,
|
|
552
|
+
offset: esc.offset,
|
|
553
|
+
followSelection: true,
|
|
554
|
+
scrollbar: true,
|
|
555
|
+
onScroll: (delta) => scrollPane(esc, delta),
|
|
556
|
+
onSelectRow: (row) => { esc.selected = esc.offset + row; },
|
|
557
|
+
columns: [
|
|
558
|
+
{ key: 'created_at', title: 'Created', width: 10, render: (r) => shortDate(r.created_at), color: theme.muted },
|
|
559
|
+
{ key: 'chain', title: 'Chain', width: 9 },
|
|
560
|
+
{ key: 'amount_usd', title: 'USD', width: 10, align: 'right', render: (r) => money(r.amount_usd) },
|
|
561
|
+
{ key: 'amount', title: 'Amount', width: 13, align: 'right', render: (r) => num(r.amount).toFixed(6), color: theme.muted },
|
|
562
|
+
{ key: 'fee_amount', title: 'Fee', width: 8, align: 'right', render: (r) => (r.fee_tx_hash ? money((num(r.fee_amount) / Math.max(num(r.amount), 1e-12)) * num(r.amount_usd)) : '—'), color: theme.warning },
|
|
563
|
+
{ key: 'status', title: 'Status', width: 10, color: (r) => statusColor(theme, r.status) },
|
|
564
|
+
{ key: 'metadata', title: 'Description', min: 10, max: 50, render: (r) => String(r.metadata?.description || r.beneficiary_email || '').slice(0, 60), color: theme.muted },
|
|
565
|
+
{ key: 'settled_at', title: 'Settled', width: 10, render: (r) => shortDate(r.settled_at || r.released_at || r.refunded_at), color: theme.muted },
|
|
566
|
+
],
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Debt against income — the basic-accounting screen.
|
|
573
|
+
*
|
|
574
|
+
* Everything here comes from `snapshot.position`, computed server-side over
|
|
575
|
+
* six months rather than the dashboard window, because a monthly charge is
|
|
576
|
+
* invisible in thirty days of rows. The window selector therefore does not
|
|
577
|
+
* move these figures, which is why the panel states its own lookback.
|
|
578
|
+
*/
|
|
579
|
+
function positionScreen(ui, state, theme) {
|
|
580
|
+
const s = state.snapshot;
|
|
581
|
+
const p0 = s.position;
|
|
582
|
+
const cur = p0?.currency || s.bank.currency || 'USD';
|
|
583
|
+
|
|
584
|
+
if (!p0) {
|
|
585
|
+
// Three different reasons, and saying the wrong one sends the reader to
|
|
586
|
+
// the wrong fix. No connection is a setup step; a failed summary is worth
|
|
587
|
+
// retrying; a summary that simply carries no `position` means the server
|
|
588
|
+
// predates this screen, which no amount of retrying will change.
|
|
589
|
+
let why;
|
|
590
|
+
if (!s.bank.connections.length) {
|
|
591
|
+
why = 'No bank linked yet. Connect one at coinpayportal.com/finances, then press s to sync.';
|
|
592
|
+
} else if (s.errors.summary) {
|
|
593
|
+
why = `The summary source failed (${s.errors.summary}), so debt and income cannot be computed. Press r to retry.`;
|
|
594
|
+
} else {
|
|
595
|
+
why =
|
|
596
|
+
'This CoinPay server does not report a debt-and-income position yet. ' +
|
|
597
|
+
'It arrives with the next deploy; every other screen works meanwhile.';
|
|
598
|
+
}
|
|
599
|
+
emptyPanel(ui, theme, 'Debt & income', why);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// The span the figures actually cover, which is what the feed holds — not
|
|
604
|
+
// the span that was requested.
|
|
605
|
+
const lookback = `${Math.round(p0.observedDays ?? p0.lookbackDays)}d`;
|
|
606
|
+
const inc = p0.income;
|
|
607
|
+
const spend = p0.spending;
|
|
608
|
+
const debt = p0.debt;
|
|
609
|
+
const r = p0.ratios;
|
|
610
|
+
|
|
611
|
+
ui.grid({ columns: ['1fr', '1fr', '1fr'], rows: [13, '1fr', 12], gap: 1 }, (grid) => {
|
|
612
|
+
grid.panel({ title: `Income vs spending · ${lookback}`, subtitle: `${p0.monthsObserved} months observed` }, (p) => {
|
|
613
|
+
p.keyValues([
|
|
614
|
+
{ label: 'Income', value: money(inc.total, cur), color: theme.success },
|
|
615
|
+
{ label: ' per month', value: money(inc.perMonth, cur), color: theme.success },
|
|
616
|
+
{ label: 'Spending', value: money(spend.total, cur), color: theme.danger },
|
|
617
|
+
{ label: ' per month', value: money(spend.perMonth, cur), color: theme.danger },
|
|
618
|
+
{ label: 'Net', value: money(p0.net.total, cur), color: signed(theme, p0.net.total) },
|
|
619
|
+
{ label: ' per month', value: money(p0.net.perMonth, cur), color: signed(theme, p0.net.perMonth) },
|
|
620
|
+
{ label: 'Kept of income', value: pct(p0.net.savingsRate, 1), color: signed(theme, p0.net.savingsRate ?? 0) },
|
|
621
|
+
// The raw sides, so the netting above is auditable rather than magic.
|
|
622
|
+
{ label: 'Gross credits', value: money(inc.grossCredits, cur), color: theme.muted },
|
|
623
|
+
{ label: 'Gross debits', value: money(spend.grossDebits, cur), color: theme.muted },
|
|
624
|
+
{ label: 'Refunds', value: money(spend.refunds, cur), color: theme.muted },
|
|
625
|
+
], { labelWidth: 16 });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
grid.panel({ title: 'Debt', subtitle: debt.total > 0 ? `${debt.accounts.length} accounts` : 'nothing owed' }, (p) => {
|
|
629
|
+
p.keyValues([
|
|
630
|
+
{ label: 'Total owed', value: money(debt.total, cur), color: theme.danger },
|
|
631
|
+
{ label: ' revolving', value: money(debt.revolving, cur) },
|
|
632
|
+
{ label: ' instalment', value: money(debt.instalment, cur) },
|
|
633
|
+
{ label: 'Paid per month', value: money(debt.servicePerMonth, cur), color: theme.success },
|
|
634
|
+
{ label: 'Fixed bills/mo', value: money(p0.recurring.monthlyTotal, cur), color: theme.warning },
|
|
635
|
+
{ label: 'Clear in', value: debt.payoffMonths === null ? 'never at this rate' : `${debt.payoffMonths} months`, color: debt.payoffMonths === null ? theme.danger : theme.foreground },
|
|
636
|
+
{ label: 'Clear by', value: debt.payoffDate ? shortDate(debt.payoffDate) : '—', color: theme.muted },
|
|
637
|
+
{ label: 'Card utilisation', value: pct(r.creditUtilisation, 0), color: (r.creditUtilisation ?? 0) > 0.3 ? theme.warning : theme.foreground },
|
|
638
|
+
], { labelWidth: 16 });
|
|
639
|
+
if (p0.confidence.noLiabilityAccounts) {
|
|
640
|
+
p.text('No card or loan account is linked, so debt is unknown.', { fg: theme.warning });
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
grid.panel({ title: 'Ratios & split', subtitle: 'business vs personal' }, (p) => {
|
|
645
|
+
p.keyValues([
|
|
646
|
+
{ label: 'Debt to income', value: r.debtToIncome === null ? '—' : `${r.debtToIncome.toFixed(2)}×`, color: (r.debtToIncome ?? 0) > 1 ? theme.danger : theme.success },
|
|
647
|
+
{ label: 'Debt service', value: pct(r.debtServiceRatio, 1), color: (r.debtServiceRatio ?? 0) > 0.36 ? theme.danger : theme.success },
|
|
648
|
+
{ label: 'Months of cover', value: r.monthsOfCover === null ? '—' : r.monthsOfCover.toFixed(1), color: (r.monthsOfCover ?? 0) < 3 ? theme.warning : theme.success },
|
|
649
|
+
...p0.scopes.flatMap((sc) => [
|
|
650
|
+
{ label: `${sc.scope} (${sc.accounts})`, value: `${money(sc.income, cur, { compact: true })} in · ${money(sc.spending, cur, { compact: true })} out`, color: theme.foreground },
|
|
651
|
+
{ label: ' owes', value: money(sc.debt, cur), color: sc.debt > 0 ? theme.danger : theme.muted },
|
|
652
|
+
]),
|
|
653
|
+
], { labelWidth: 16 });
|
|
654
|
+
const share = p0.confidence.uncategorisedShare;
|
|
655
|
+
if (share > 0.25) {
|
|
656
|
+
p.text(`${pct(share, 0)} of rows are uncategorised — treat the split as rough.`, { fg: theme.warning });
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
grid.panel({ title: `Month by month · ${lookback}`, colSpan: 3, subtitle: 'income vs spending; first and last months are partial' }, (p) => {
|
|
661
|
+
const pts = p0.months;
|
|
662
|
+
if (!pts.length) {
|
|
663
|
+
p.text('No transactions in this window.', { fg: theme.muted });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
p.multiGraph(
|
|
667
|
+
[
|
|
668
|
+
{ values: pts.map((m) => m.income), color: theme.success, label: 'income', fill: true },
|
|
669
|
+
{ values: pts.map((m) => m.spending), color: theme.danger, label: 'spending' },
|
|
670
|
+
{ values: pts.map((m) => m.debtService), color: theme.warning, label: 'debt paid' },
|
|
671
|
+
],
|
|
672
|
+
{
|
|
673
|
+
min: 0,
|
|
674
|
+
axis: true,
|
|
675
|
+
axisFormat: (v) => money(v, cur, { compact: true }),
|
|
676
|
+
timeAxis: pts.map((m) => m.month.slice(2)),
|
|
677
|
+
legend: true,
|
|
678
|
+
},
|
|
679
|
+
);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
grid.panel({ title: `Owed by account (${debt.accounts.length})`, colSpan: 2, footer: 'j/k scroll' }, (p) => {
|
|
683
|
+
if (!debt.accounts.length) {
|
|
684
|
+
p.text('No debt on any linked account.', { fg: theme.muted });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const debts = pane(state, 'debts', debt.accounts.length);
|
|
688
|
+
p.table({
|
|
689
|
+
rows: debt.accounts,
|
|
690
|
+
selected: debts.selected,
|
|
691
|
+
offset: debts.offset,
|
|
692
|
+
followSelection: true,
|
|
693
|
+
scrollbar: true,
|
|
694
|
+
onScroll: (delta) => scrollPane(debts, delta),
|
|
695
|
+
onSelectRow: (row) => { debts.selected = debts.offset + row; },
|
|
696
|
+
columns: [
|
|
697
|
+
{ key: 'org', title: 'Institution', width: 20, render: (x) => x.org || '—', color: theme.muted },
|
|
698
|
+
{ key: 'name', title: 'Account', min: 14, max: 32 },
|
|
699
|
+
{ key: 'kind', title: 'Kind', width: 8, color: theme.muted },
|
|
700
|
+
{ key: 'scope', title: 'Side', width: 9, color: (x) => (x.scope === 'business' ? theme.info : theme.muted) },
|
|
701
|
+
{ key: 'owed', title: 'Owed', width: 12, align: 'right', render: (x) => money(x.owed, cur), color: theme.danger },
|
|
702
|
+
{ key: 'share', title: 'Share', width: 6, align: 'right', render: (x) => pct(x.share, 0), color: theme.muted },
|
|
703
|
+
{ key: 'paid', title: `Paid ${lookback}`, width: 12, align: 'right', render: (x) => money(x.paid, cur), color: theme.success },
|
|
704
|
+
{ key: 'payoffMonths', title: 'Clear in', width: 9, align: 'right', render: (x) => (x.payoffMonths === null ? 'never' : `${x.payoffMonths} mo`), color: (x) => (x.payoffMonths === null ? theme.danger : theme.foreground) },
|
|
705
|
+
],
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
grid.panel({ title: `Recurring (${p0.recurring.charges.length})`, subtitle: `${money(p0.recurring.monthlyTotal, cur)}/mo` }, (p) => {
|
|
710
|
+
const charges = p0.recurring.charges;
|
|
711
|
+
if (!charges.length) {
|
|
712
|
+
p.text('Nothing recurring found in this window.', { fg: theme.muted });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const max = Math.max(1, ...charges.map((c) => c.monthlyEquivalent));
|
|
716
|
+
p.meters(
|
|
717
|
+
charges.slice(0, 10).map((c) => ({
|
|
718
|
+
label: c.payee.slice(0, 16),
|
|
719
|
+
value: c.monthlyEquivalent,
|
|
720
|
+
max,
|
|
721
|
+
color: c.isDebtService ? theme.warning : theme.info,
|
|
722
|
+
text: `${money(c.monthlyEquivalent, cur, { compact: true })}/mo`,
|
|
723
|
+
})),
|
|
724
|
+
{ labelWidth: 17, valueWidth: 9 },
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const SCREENS = [overviewScreen, bankScreen, ledgerScreen, cryptoScreen, cardsScreen, invoicesScreen, positionScreen];
|
|
731
|
+
|
|
732
|
+
// ── App ──
|
|
733
|
+
|
|
734
|
+
async function loadHqtui() {
|
|
735
|
+
try {
|
|
736
|
+
return await import('@profullstack/hqtui');
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
739
|
+
const tooOld = major < 22 || (major === 22 && minor < 6);
|
|
740
|
+
const hint = tooOld
|
|
741
|
+
? `The dashboard needs Node 22.6 or newer (you have ${process.versions.node}). Run \`coinpay update\` to install a current Node, or use \`coinpay finances summary\`.`
|
|
742
|
+
: `Could not load @profullstack/hqtui: ${err?.message || err}. Run \`coinpay update\`, or use \`coinpay finances summary\`.`;
|
|
743
|
+
const error = new Error(hint);
|
|
744
|
+
error.cause = err;
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Run the dashboard until the user quits. Resolves when the terminal has been
|
|
751
|
+
* restored.
|
|
752
|
+
*/
|
|
753
|
+
export async function runFinancesTui({ client, baseUrl, token, days = 30, interval = 30, businessId, limit = 100, theme } = {}) {
|
|
754
|
+
const hqtui = await loadHqtui();
|
|
755
|
+
const app = await hqtui.createApp({ fps: 30, theme: theme || 'dark', quitKeys: ['ctrl+c', 'q'] });
|
|
756
|
+
const state = createState(days);
|
|
757
|
+
|
|
758
|
+
let refreshing = false;
|
|
759
|
+
let refreshTimer = null;
|
|
760
|
+
|
|
761
|
+
async function refresh(reason = 'timer') {
|
|
762
|
+
if (refreshing) return;
|
|
763
|
+
refreshing = true;
|
|
764
|
+
state.loading = true;
|
|
765
|
+
app.invalidate();
|
|
766
|
+
try {
|
|
767
|
+
const snapshot = await collectFinanceSnapshot(client, { days: state.days, limit, businessId });
|
|
768
|
+
noteNewRows(state, snapshot);
|
|
769
|
+
state.snapshot = snapshot;
|
|
770
|
+
state.error = null;
|
|
771
|
+
state.lastRefresh = new Date();
|
|
772
|
+
const failed = Object.keys(snapshot.errors);
|
|
773
|
+
if (failed.length && reason === 'startup') {
|
|
774
|
+
pushLive(state, { level: 'INFO', message: `some sources unavailable: ${failed.join(', ')}`, meta: '' });
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
state.error = err?.message || String(err);
|
|
778
|
+
pushLive(state, { level: 'ERR', message: state.error, meta: reason });
|
|
779
|
+
} finally {
|
|
780
|
+
state.loading = false;
|
|
781
|
+
refreshing = false;
|
|
782
|
+
app.invalidate();
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function scheduleRefresh(ms = 1500) {
|
|
787
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
788
|
+
refreshTimer = setTimeout(() => { refreshTimer = null; void refresh('event'); }, ms);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function sync() {
|
|
792
|
+
if (state.syncing) return;
|
|
793
|
+
if (!state.snapshot?.bank?.connections?.length) {
|
|
794
|
+
state.notice = 'No bank connection to sync. Link one at coinpayportal.com/finances.';
|
|
795
|
+
app.invalidate();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
state.syncing = true;
|
|
799
|
+
state.notice = null;
|
|
800
|
+
pushLive(state, { level: 'SYNC', message: 'bank sync started', meta: '' });
|
|
801
|
+
app.invalidate();
|
|
802
|
+
try {
|
|
803
|
+
const result = await syncFinances(client, {});
|
|
804
|
+
const t = result.totals || {};
|
|
805
|
+
state.notice = `Synced ${t.accounts ?? 0} accounts, ${t.transactionsNew ?? 0} new of ${t.transactionsSeen ?? 0} transactions (${result.status}).`;
|
|
806
|
+
pushLive(state, { level: 'SYNC', message: state.notice, meta: '' });
|
|
807
|
+
await refresh('sync');
|
|
808
|
+
} catch (err) {
|
|
809
|
+
state.notice = `Sync failed: ${err?.message || err}`;
|
|
810
|
+
pushLive(state, { level: 'ERR', message: state.notice, meta: 'sync' });
|
|
811
|
+
} finally {
|
|
812
|
+
state.syncing = false;
|
|
813
|
+
app.invalidate();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const poll = setInterval(() => {
|
|
818
|
+
if (!state.paused) void refresh('timer');
|
|
819
|
+
}, Math.max(5, interval) * 1000);
|
|
820
|
+
poll.unref?.();
|
|
821
|
+
|
|
822
|
+
const tick = setInterval(() => app.invalidate(), 1000);
|
|
823
|
+
tick.unref?.();
|
|
824
|
+
|
|
825
|
+
let closeStream = () => {};
|
|
826
|
+
if (baseUrl && token) {
|
|
827
|
+
closeStream = subscribeToPayments({
|
|
828
|
+
baseUrl,
|
|
829
|
+
token,
|
|
830
|
+
businessId,
|
|
831
|
+
onStatus: (status, detail) => {
|
|
832
|
+
state.liveStatus = status === 'error' ? `stream error: ${detail}` : `stream ${status}`;
|
|
833
|
+
app.invalidate();
|
|
834
|
+
},
|
|
835
|
+
onEvent: (event) => {
|
|
836
|
+
if (!event || event.type === 'heartbeat') return;
|
|
837
|
+
if (event.type === 'connected') {
|
|
838
|
+
pushLive(state, { level: 'SSE', message: 'live payment stream connected', meta: '' });
|
|
839
|
+
} else if (event.payment) {
|
|
840
|
+
const p = event.payment;
|
|
841
|
+
pushLive(state, {
|
|
842
|
+
level: 'PAY',
|
|
843
|
+
message: `${event.type.replace('payment_', '')} ${money(p.amount_usd)} ${p.currency || ''} · ${p.status}${p.confirmations != null ? ` · ${p.confirmations}/${p.required_confirmations ?? '?'} conf` : ''}`,
|
|
844
|
+
meta: shortId(p.id),
|
|
845
|
+
});
|
|
846
|
+
scheduleRefresh();
|
|
847
|
+
}
|
|
848
|
+
app.invalidate();
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
} else {
|
|
852
|
+
state.liveStatus = 'polling only';
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
app.on('key', (event) => {
|
|
856
|
+
if (state.showHelp) {
|
|
857
|
+
state.showHelp = false;
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const name = event.name;
|
|
861
|
+
const digit = Number(name);
|
|
862
|
+
if (Number.isInteger(digit) && name.length === 1 && digit >= 1 && digit <= TABS.length) {
|
|
863
|
+
state.tab = digit - 1;
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
switch (name) {
|
|
867
|
+
case 'tab':
|
|
868
|
+
case 'right':
|
|
869
|
+
case 'l':
|
|
870
|
+
state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length;
|
|
871
|
+
break;
|
|
872
|
+
case 'left':
|
|
873
|
+
case 'h':
|
|
874
|
+
state.tab = (state.tab + TABS.length - 1) % TABS.length;
|
|
875
|
+
break;
|
|
876
|
+
case 'r':
|
|
877
|
+
case 'f5':
|
|
878
|
+
void refresh('manual');
|
|
879
|
+
break;
|
|
880
|
+
case 's':
|
|
881
|
+
void sync();
|
|
882
|
+
break;
|
|
883
|
+
case 'w':
|
|
884
|
+
state.days = WINDOWS[(WINDOWS.indexOf(state.days) + 1) % WINDOWS.length] ?? 30;
|
|
885
|
+
void refresh('window');
|
|
886
|
+
break;
|
|
887
|
+
case 'p':
|
|
888
|
+
case 'space':
|
|
889
|
+
state.paused = !state.paused;
|
|
890
|
+
break;
|
|
891
|
+
case '?':
|
|
892
|
+
case 'f1':
|
|
893
|
+
state.showHelp = true;
|
|
894
|
+
break;
|
|
895
|
+
case 'up':
|
|
896
|
+
case 'k':
|
|
897
|
+
moveSelection(pane(state, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? 1 : -1);
|
|
898
|
+
break;
|
|
899
|
+
case 'down':
|
|
900
|
+
case 'j':
|
|
901
|
+
moveSelection(pane(state, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? -1 : 1);
|
|
902
|
+
break;
|
|
903
|
+
case 'pageup':
|
|
904
|
+
scrollPane(pane(state, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? 1 : -1, 10);
|
|
905
|
+
break;
|
|
906
|
+
case 'pagedown':
|
|
907
|
+
scrollPane(pane(state, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? -1 : 1, 10);
|
|
908
|
+
break;
|
|
909
|
+
case 'home': {
|
|
910
|
+
const p = pane(state, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0);
|
|
911
|
+
p.selected = 0; p.offset = state.tab === 0 ? Math.max(0, p.total - 1) : 0;
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case 'end': {
|
|
915
|
+
const p = pane(state, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0);
|
|
916
|
+
p.selected = Math.max(0, p.total - 1); p.offset = state.tab === 0 ? 0 : p.selected;
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
default:
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
app.invalidate();
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
app.render(({ ui, theme: t, height }) => {
|
|
926
|
+
ui.row({ size: 1 }, (header) => {
|
|
927
|
+
header.text(' CoinPay ', { fg: t.title, bold: true, size: 10 });
|
|
928
|
+
header.tabs({
|
|
929
|
+
tabs: TABS.map((name, i) => `${i + 1} ${name}`),
|
|
930
|
+
active: state.tab,
|
|
931
|
+
onSelect: (index) => { state.tab = index; },
|
|
932
|
+
});
|
|
933
|
+
const right = [
|
|
934
|
+
state.paused ? 'paused' : state.loading ? 'loading…' : `${state.days}d`,
|
|
935
|
+
state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : 'starting',
|
|
936
|
+
clock(),
|
|
937
|
+
].join(' ');
|
|
938
|
+
header.text(`${right} `, { fg: state.paused ? t.warning : state.error ? t.danger : t.success, align: 'right' });
|
|
939
|
+
});
|
|
940
|
+
ui.spacer(1);
|
|
941
|
+
|
|
942
|
+
ui.column({ size: height - 4 }, (body) => {
|
|
943
|
+
if (!state.snapshot) {
|
|
944
|
+
body.panel({ title: 'Finances' }, (p) => {
|
|
945
|
+
p.text(state.error ? `Could not load: ${state.error}` : 'Loading your numbers…', { fg: state.error ? t.danger : t.muted });
|
|
946
|
+
if (state.error) p.text('Press r to retry, q to quit.', { fg: t.muted });
|
|
947
|
+
});
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
SCREENS[state.tab](body, state, t);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
ui.spacer(1);
|
|
954
|
+
const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0;
|
|
955
|
+
ui.statusBar({
|
|
956
|
+
items: [
|
|
957
|
+
{ key: '1-7', label: 'Screen' },
|
|
958
|
+
{ key: 'r', label: 'Refresh' },
|
|
959
|
+
{ key: 's', label: state.syncing ? 'Syncing…' : 'Sync bank', active: state.syncing },
|
|
960
|
+
{ key: 'w', label: `Window ${state.days}d` },
|
|
961
|
+
{ key: 'p', label: state.paused ? 'Resume' : 'Pause', active: state.paused },
|
|
962
|
+
{ key: '?', label: 'Help' },
|
|
963
|
+
{ key: 'q', label: 'Quit' },
|
|
964
|
+
],
|
|
965
|
+
right: [
|
|
966
|
+
...(errorCount ? [{ label: `${errorCount} source${errorCount > 1 ? 's' : ''} unavailable`, color: t.warning }] : []),
|
|
967
|
+
{ label: state.liveStatus, color: state.liveStatus === 'stream connected' ? t.success : t.muted },
|
|
968
|
+
],
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
if (state.showHelp) {
|
|
972
|
+
ui.modal({
|
|
973
|
+
title: 'CoinPay Finances — Help',
|
|
974
|
+
width: 66,
|
|
975
|
+
height: 20,
|
|
976
|
+
message:
|
|
977
|
+
'1-7, Tab, ←/→ switch screens.\n' +
|
|
978
|
+
'r refreshes now; refresh also runs every ' + Math.max(5, interval) + 's.\n' +
|
|
979
|
+
's pulls fresh bank balances and transactions (rate-limited\n' +
|
|
980
|
+
' by the bank bridge, so it is never automatic).\n' +
|
|
981
|
+
'w cycles the window: 7 → 30 → 90 → 365 days.\n' +
|
|
982
|
+
'p pauses the timer. ↑/↓ j/k, PgUp/PgDn, Home/End scroll.\n' +
|
|
983
|
+
'Mouse: click tabs, scroll tables.\n\n' +
|
|
984
|
+
'Commission paid = platform fees on crypto + card payments.\n' +
|
|
985
|
+
'Net earnings = gross − commission − processor fees − refunds.\n' +
|
|
986
|
+
'Debt & Income covers 180 days regardless of w, and excludes\n' +
|
|
987
|
+
' transfers and card payments from both sides.\n\n' +
|
|
988
|
+
'Press any key to close.',
|
|
989
|
+
buttons: [{ label: 'Close', focused: true }],
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
app.on('exit', () => {
|
|
995
|
+
clearInterval(poll);
|
|
996
|
+
clearInterval(tick);
|
|
997
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
998
|
+
closeStream();
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
void refresh('startup');
|
|
1002
|
+
await app.start();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
export { TABS as FINANCE_TABS, WINDOWS as FINANCE_WINDOWS };
|