@profullstack/coinpay 0.11.1 → 0.12.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 +24 -0
- package/README.md +26 -0
- package/bin/coinpay.js +24 -0
- package/package.json +1 -1
- package/src/finances-commands.js +127 -1
- package/src/finances-reports.js +16 -0
- package/src/finances-tui.js +232 -140
- package/src/mcp.js +152 -0
- package/src/statements-fetch.js +959 -0
- package/src/x402.js +12 -10
package/src/finances-tui.js
CHANGED
|
@@ -64,6 +64,12 @@ export function shortDateTime(value) {
|
|
|
64
64
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function detailDateTime(value) {
|
|
68
|
+
if (!value) return '—';
|
|
69
|
+
const d = new Date(value);
|
|
70
|
+
return Number.isNaN(d.getTime()) ? String(value) : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
export function ago(value) {
|
|
68
74
|
if (!value) return 'never';
|
|
69
75
|
const ms = Date.now() - new Date(value).getTime();
|
|
@@ -143,6 +149,38 @@ function moveSelection(p, delta) {
|
|
|
143
149
|
// The table each screen scrolls with the keyboard.
|
|
144
150
|
const TAB_PANE = ['live', 'accounts', 'ledger', 'payments', 'cards', 'invoices', 'debts'];
|
|
145
151
|
|
|
152
|
+
function activePane(state) {
|
|
153
|
+
return state.tab === 5 && state.invoicePane === 'escrows' ? 'escrows' : TAB_PANE[state.tab];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** hqtui reports visible row numbers on clicks, but may move its viewport to
|
|
157
|
+
* follow keyboard selection or clamp the last page. Record the rows actually
|
|
158
|
+
* drawn instead of assuming that the requested offset is still on screen. */
|
|
159
|
+
function selectableTable(panel, state, name, rows, columns) {
|
|
160
|
+
const selection = pane(state, name, rows.length);
|
|
161
|
+
const visibleRows = new Map();
|
|
162
|
+
panel.table({
|
|
163
|
+
rows,
|
|
164
|
+
columns,
|
|
165
|
+
selected: selection.selected,
|
|
166
|
+
offset: selection.offset,
|
|
167
|
+
followSelection: true,
|
|
168
|
+
scrollbar: true,
|
|
169
|
+
onRow: (_row, index, y) => {
|
|
170
|
+
visibleRows.set(y - 1, index);
|
|
171
|
+
if (y === 1) selection.offset = index;
|
|
172
|
+
},
|
|
173
|
+
onFocus: () => {
|
|
174
|
+
if (name === 'invoices' || name === 'escrows') state.invoicePane = name;
|
|
175
|
+
},
|
|
176
|
+
onScroll: (delta) => scrollPane(selection, delta),
|
|
177
|
+
onSelectRow: (visibleRow) => {
|
|
178
|
+
const index = visibleRows.get(visibleRow);
|
|
179
|
+
if (index !== undefined) selection.selected = index;
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
function pushLive(state, entry) {
|
|
147
185
|
state.live.push({ time: clock(), ...entry });
|
|
148
186
|
if (state.live.length > LIVE_MAX) state.live.splice(0, state.live.length - LIVE_MAX);
|
|
@@ -193,6 +231,27 @@ function emptyPanel(ui, theme, title, message) {
|
|
|
193
231
|
ui.panel({ title }, (p) => p.text(message, { fg: theme.muted }));
|
|
194
232
|
}
|
|
195
233
|
|
|
234
|
+
/** Render the record under the pointer beside a table. Tables keep their
|
|
235
|
+
* selection in state, so a mouse click and j/k navigation show the same
|
|
236
|
+
* detail record without opening a second screen. */
|
|
237
|
+
function selectedDetails(panel, title, row, fields, theme, options = {}) {
|
|
238
|
+
const { after, ...panelOptions } = options;
|
|
239
|
+
panel.panel({ title: row ? title : `${title} · none`, ...panelOptions }, (p) => {
|
|
240
|
+
if (!row) {
|
|
241
|
+
p.text('Click a row to inspect its details.', { fg: theme.muted });
|
|
242
|
+
after?.(p);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
for (const [label, value, color] of fields) {
|
|
246
|
+
p.text([
|
|
247
|
+
{ text: `${label}: `, fg: theme.muted },
|
|
248
|
+
{ text: value === null || value === undefined || value === '' ? '—' : String(value), fg: color || theme.foreground },
|
|
249
|
+
], { wrap: true });
|
|
250
|
+
}
|
|
251
|
+
after?.(p);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
196
255
|
function overviewScreen(ui, state, theme) {
|
|
197
256
|
const s = state.snapshot;
|
|
198
257
|
const e = s.earnings;
|
|
@@ -297,18 +356,11 @@ function bankScreen(ui, state, theme) {
|
|
|
297
356
|
emptyPanel(ui, theme, 'Bank & cards', 'No bank linked yet. Connect one at coinpayportal.com/finances (SimpleFIN or Plaid), then press s to sync.');
|
|
298
357
|
return;
|
|
299
358
|
}
|
|
359
|
+
const accounts = pane(state, 'accounts', b.accounts.length);
|
|
360
|
+
const selectedAccount = b.accounts[accounts.selected];
|
|
300
361
|
ui.grid({ columns: ['3fr', '2fr'], gap: 1 }, (grid) => {
|
|
301
362
|
grid.panel({ title: `Accounts (${b.accounts.length})`, footer: 'j/k scroll · s sync' }, (p) => {
|
|
302
|
-
|
|
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: [
|
|
363
|
+
selectableTable(p, state, 'accounts', b.accounts, [
|
|
312
364
|
{ key: 'org_name', title: 'Institution', width: 22, render: (r) => r.org_name || '—', color: theme.muted },
|
|
313
365
|
{ key: 'name', title: 'Account', min: 14, max: 36 },
|
|
314
366
|
{ key: 'effective_kind', title: 'Kind', width: 10, color: (r) => (r.is_liability ? theme.danger : theme.success) },
|
|
@@ -318,11 +370,19 @@ function bankScreen(ui, state, theme) {
|
|
|
318
370
|
{ 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) },
|
|
319
371
|
{ 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 },
|
|
320
372
|
{ key: 'balance_date', title: 'As of', width: 10, render: (r) => shortDate(r.balance_date), color: theme.muted },
|
|
321
|
-
|
|
322
|
-
});
|
|
373
|
+
]);
|
|
323
374
|
});
|
|
324
375
|
|
|
325
376
|
grid.cell({ gap: 1 }, (col) => {
|
|
377
|
+
selectedDetails(col, 'Selected account', selectedAccount, [
|
|
378
|
+
['Institution', selectedAccount?.org_name],
|
|
379
|
+
['Account', selectedAccount?.name],
|
|
380
|
+
['Kind', selectedAccount?.effective_kind],
|
|
381
|
+
['Side', selectedAccount?.effective_scope],
|
|
382
|
+
['Balance', selectedAccount ? money(selectedAccount.display_balance ?? selectedAccount.balance ?? 0, selectedAccount.currency || cur) : null, selectedAccount?.is_liability ? theme.danger : theme.success],
|
|
383
|
+
['As of', selectedAccount ? shortDateTime(selectedAccount.balance_date) : null],
|
|
384
|
+
['Last seen', selectedAccount ? shortDateTime(selectedAccount.last_seen_at) : null],
|
|
385
|
+
], theme, { size: '55%' });
|
|
326
386
|
col.panel({ title: 'Owed by institution', size: Math.min(14, b.byInstitution.length + 3) }, (p) => {
|
|
327
387
|
const rows = b.byInstitution.filter((i) => i.liabilities > 0 || i.assets > 0);
|
|
328
388
|
const max = Math.max(1, ...rows.map((i) => Math.max(i.liabilities, i.assets)));
|
|
@@ -368,29 +428,31 @@ function ledgerScreen(ui, state, theme) {
|
|
|
368
428
|
emptyPanel(ui, theme, 'Ledger', b.connections.length ? 'No transactions in this window. Press s to sync.' : 'No bank linked yet.');
|
|
369
429
|
return;
|
|
370
430
|
}
|
|
371
|
-
|
|
431
|
+
const ledger = pane(state, 'ledger', b.ledger.length);
|
|
432
|
+
const selectedLedger = b.ledger[ledger.selected];
|
|
433
|
+
ui.grid({ columns: ['3fr', '2fr'], gap: 1 }, (grid) => {
|
|
372
434
|
grid.panel({ title: `Ledger · ${state.days}d`, subtitle: `${b.ledger.length} of ${b.ledgerTotal}`, footer: 'newest first' }, (p) => {
|
|
373
|
-
|
|
374
|
-
p.table({
|
|
375
|
-
rows: b.ledger,
|
|
376
|
-
selected: ledger.selected,
|
|
377
|
-
offset: ledger.offset,
|
|
378
|
-
followSelection: true,
|
|
379
|
-
scrollbar: true,
|
|
380
|
-
zebra: true,
|
|
381
|
-
onScroll: (delta) => scrollPane(ledger, delta),
|
|
382
|
-
onSelectRow: (row) => { ledger.selected = ledger.offset + row; },
|
|
383
|
-
columns: [
|
|
435
|
+
selectableTable(p, state, 'ledger', b.ledger, [
|
|
384
436
|
{ key: 'posted', title: 'Date', width: 10, render: (r) => shortDate(r.transacted_at || r.posted), color: theme.muted },
|
|
385
437
|
{ key: 'account_name', title: 'Account', width: 26, render: (r) => `${r.org_name ? r.org_name.split(' ')[0] + ' ' : ''}${r.account_name}` },
|
|
386
438
|
{ key: 'payee', title: 'Payee / description', min: 14, max: 40, render: (r) => r.payee || r.description || r.memo || '—' },
|
|
387
439
|
{ key: 'category', title: 'Category', width: 14, render: (r) => r.category || '—', color: theme.muted },
|
|
388
440
|
{ key: 'pending', title: '', width: 1, render: (r) => (r.pending ? '•' : ''), color: theme.warning },
|
|
389
441
|
{ key: 'amount', title: 'Amount', width: 12, align: 'right', render: (r) => money(r.amount, r.currency || cur), color: (r) => signed(theme, r.amount) },
|
|
390
|
-
|
|
391
|
-
});
|
|
442
|
+
]);
|
|
392
443
|
});
|
|
393
444
|
grid.cell({ gap: 1 }, (col) => {
|
|
445
|
+
selectedDetails(col, 'Selected transaction', selectedLedger, [
|
|
446
|
+
['Date', selectedLedger ? shortDateTime(selectedLedger.transacted_at || selectedLedger.posted) : null],
|
|
447
|
+
['Account', selectedLedger?.account_name],
|
|
448
|
+
['Institution', selectedLedger?.org_name],
|
|
449
|
+
['Payee', selectedLedger?.payee || selectedLedger?.description],
|
|
450
|
+
['Category', selectedLedger?.category],
|
|
451
|
+
['Amount', selectedLedger ? money(selectedLedger.amount, selectedLedger.currency || cur) : null, selectedLedger ? signed(theme, selectedLedger.amount) : undefined],
|
|
452
|
+
['Description', selectedLedger?.description],
|
|
453
|
+
['Memo', selectedLedger?.memo],
|
|
454
|
+
['Transaction ID', selectedLedger?.id],
|
|
455
|
+
], theme, { size: '60%' });
|
|
394
456
|
col.panel({ title: `Cashflow · ${state.days}d`, size: 7 }, (p) => {
|
|
395
457
|
p.keyValues([
|
|
396
458
|
{ label: 'In', value: money(b.cashflow.moneyIn, cur), color: theme.success },
|
|
@@ -414,22 +476,15 @@ function ledgerScreen(ui, state, theme) {
|
|
|
414
476
|
function cryptoScreen(ui, state, theme) {
|
|
415
477
|
const s = state.snapshot;
|
|
416
478
|
const rows = s.recent.payments;
|
|
417
|
-
|
|
479
|
+
const payments = pane(state, 'payments', rows.length);
|
|
480
|
+
const selectedPayment = rows[payments.selected];
|
|
481
|
+
ui.grid({ columns: ['3fr', '2fr'], gap: 1 }, (grid) => {
|
|
418
482
|
grid.panel({ title: `Crypto payments · ${state.days}d`, subtitle: s.crypto.partial ? 'latest page' : undefined }, (p) => {
|
|
419
483
|
if (!rows.length) {
|
|
420
484
|
p.text('No crypto payments in this window.', { fg: theme.muted });
|
|
421
485
|
return;
|
|
422
486
|
}
|
|
423
|
-
|
|
424
|
-
p.table({
|
|
425
|
-
rows,
|
|
426
|
-
selected: payments.selected,
|
|
427
|
-
offset: payments.offset,
|
|
428
|
-
followSelection: true,
|
|
429
|
-
scrollbar: true,
|
|
430
|
-
onScroll: (delta) => scrollPane(payments, delta),
|
|
431
|
-
onSelectRow: (row) => { payments.selected = payments.offset + row; },
|
|
432
|
-
columns: [
|
|
487
|
+
selectableTable(p, state, 'payments', rows, [
|
|
433
488
|
{ key: 'created_at', title: 'When', width: 11, render: (r) => shortDateTime(r.created_at), color: theme.muted },
|
|
434
489
|
{ key: 'business_name', title: 'Business', min: 10, max: 28, render: (r) => r.business_name || shortId(r.business_id) },
|
|
435
490
|
{ key: 'currency', title: 'Chain', width: 9 },
|
|
@@ -438,10 +493,18 @@ function cryptoScreen(ui, state, theme) {
|
|
|
438
493
|
{ 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 },
|
|
439
494
|
{ key: 'status', title: 'Status', width: 11, color: (r) => statusColor(theme, r.status) },
|
|
440
495
|
{ key: 'tx_hash', title: 'Tx', width: 10, render: (r) => shortId(r.forward_tx_hash || r.tx_hash || '', 9), color: theme.muted },
|
|
441
|
-
|
|
442
|
-
});
|
|
496
|
+
]);
|
|
443
497
|
});
|
|
444
498
|
grid.cell({ gap: 1 }, (col) => {
|
|
499
|
+
selectedDetails(col, 'Selected crypto payment', selectedPayment, [
|
|
500
|
+
['When', selectedPayment ? shortDateTime(selectedPayment.created_at) : null],
|
|
501
|
+
['Business', selectedPayment?.business_name || selectedPayment?.business_id],
|
|
502
|
+
['Status', selectedPayment?.status, selectedPayment ? statusColor(theme, selectedPayment.status) : undefined],
|
|
503
|
+
['Chain', selectedPayment?.currency],
|
|
504
|
+
['Crypto amount', selectedPayment ? num(selectedPayment.amount_crypto).toFixed(6) : null],
|
|
505
|
+
['USD amount', selectedPayment ? money(selectedPayment.amount_usd) : null, theme.success],
|
|
506
|
+
['Transaction', selectedPayment ? (selectedPayment.forward_tx_hash || selectedPayment.tx_hash) : null],
|
|
507
|
+
], theme, { size: '60%' });
|
|
445
508
|
col.panel({ title: 'Totals', size: 9 }, (p) => {
|
|
446
509
|
p.keyValues([
|
|
447
510
|
{ label: 'Volume', value: money(s.earnings.cryptoVolumeUsd), color: theme.primary },
|
|
@@ -465,22 +528,15 @@ function cryptoScreen(ui, state, theme) {
|
|
|
465
528
|
function cardsScreen(ui, state, theme) {
|
|
466
529
|
const s = state.snapshot;
|
|
467
530
|
const rows = s.recent.cards;
|
|
468
|
-
|
|
531
|
+
const cards = pane(state, 'cards', rows.length);
|
|
532
|
+
const selectedCard = rows[cards.selected];
|
|
533
|
+
ui.grid({ columns: ['3fr', '2fr'], gap: 1 }, (grid) => {
|
|
469
534
|
grid.panel({ title: `Card payments · ${state.days}d`, subtitle: s.card.partial ? 'latest page' : undefined }, (p) => {
|
|
470
535
|
if (!rows.length) {
|
|
471
536
|
p.text('No card payments in this window.', { fg: theme.muted });
|
|
472
537
|
return;
|
|
473
538
|
}
|
|
474
|
-
|
|
475
|
-
p.table({
|
|
476
|
-
rows,
|
|
477
|
-
selected: cards.selected,
|
|
478
|
-
offset: cards.offset,
|
|
479
|
-
followSelection: true,
|
|
480
|
-
scrollbar: true,
|
|
481
|
-
onScroll: (delta) => scrollPane(cards, delta),
|
|
482
|
-
onSelectRow: (row) => { cards.selected = cards.offset + row; },
|
|
483
|
-
columns: [
|
|
539
|
+
selectableTable(p, state, 'cards', rows, [
|
|
484
540
|
{ key: 'created_at', title: 'When', width: 11, render: (r) => shortDateTime(r.created_at), color: theme.muted },
|
|
485
541
|
{ key: 'business_name', title: 'Business', min: 10, max: 26, render: (r) => r.business_name || shortId(r.business_id) },
|
|
486
542
|
{ key: 'customer_email', title: 'Customer', min: 10, max: 26, render: (r) => r.customer_name || r.customer_email || '—', color: theme.muted },
|
|
@@ -489,10 +545,18 @@ function cardsScreen(ui, state, theme) {
|
|
|
489
545
|
{ key: 'stripe_fee_amount', title: 'Proc fee', width: 9, align: 'right', render: (r) => money(num(r.stripe_fee_amount) / 100), color: theme.muted },
|
|
490
546
|
{ key: 'net_to_merchant', title: 'Net', width: 10, align: 'right', render: (r) => money(num(r.net_to_merchant) / 100), color: theme.success },
|
|
491
547
|
{ key: 'status', title: 'Status', width: 11, color: (r) => statusColor(theme, r.status) },
|
|
492
|
-
|
|
493
|
-
});
|
|
548
|
+
]);
|
|
494
549
|
});
|
|
495
550
|
grid.cell({ gap: 1 }, (col) => {
|
|
551
|
+
selectedDetails(col, 'Selected card payment', selectedCard, [
|
|
552
|
+
['When', selectedCard ? shortDateTime(selectedCard.created_at) : null],
|
|
553
|
+
['Business', selectedCard?.business_name || selectedCard?.business_id],
|
|
554
|
+
['Customer', selectedCard?.customer_name || selectedCard?.customer_email],
|
|
555
|
+
['Status', selectedCard?.status, selectedCard ? statusColor(theme, selectedCard.status) : undefined],
|
|
556
|
+
['Amount', selectedCard ? money(num(selectedCard.amount_cents) / 100, (selectedCard.currency || 'usd').toUpperCase()) : null],
|
|
557
|
+
['Net to merchant', selectedCard ? money(num(selectedCard.net_to_merchant) / 100) : null, theme.success],
|
|
558
|
+
['Payment ID', selectedCard?.id],
|
|
559
|
+
], theme, { size: '55%' });
|
|
496
560
|
col.panel({ title: 'Totals' }, (p) => {
|
|
497
561
|
p.keyValues([
|
|
498
562
|
{ label: 'Volume', value: money(s.earnings.cardVolumeUsd), color: theme.primary },
|
|
@@ -515,58 +579,80 @@ function invoicesScreen(ui, state, theme) {
|
|
|
515
579
|
const s = state.snapshot;
|
|
516
580
|
const invoices = s.invoices.rows;
|
|
517
581
|
const escrows = s.recent.escrows;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
582
|
+
const inv = pane(state, 'invoices', invoices.length);
|
|
583
|
+
const esc = pane(state, 'escrows', escrows.length);
|
|
584
|
+
if (!state.invoicePane) state.invoicePane = invoices.length ? 'invoices' : 'escrows';
|
|
585
|
+
ui.grid({ columns: ['3fr', '2fr'], gap: 1 }, (grid) => {
|
|
586
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
587
|
+
col.panel({
|
|
588
|
+
title: `Invoices (${invoices.length})`,
|
|
589
|
+
subtitle: `outstanding ${money(s.invoices.totals.outstanding)} · overdue ${money(s.invoices.totals.overdue)} · paid ${money(s.invoices.totals.paid)}`,
|
|
590
|
+
size: '55%',
|
|
591
|
+
footer: 'click row for details · j/k navigate',
|
|
592
|
+
}, (p) => {
|
|
593
|
+
if (!invoices.length) { p.text('No invoices.', { fg: theme.muted }); return; }
|
|
594
|
+
selectableTable(p, state, 'invoices', invoices, [
|
|
595
|
+
{ key: 'invoice_number', title: 'No.', width: 9 },
|
|
596
|
+
{ key: 'created_at', title: 'Created', width: 10, min: 10, render: (r) => shortDate(r.created_at), color: theme.muted },
|
|
597
|
+
{ key: 'due_date', title: 'Due', width: 10, min: 10, render: (r) => shortDate(r.due_date), color: (r) => (r.due_date && !['paid', 'cancelled'].includes(r.status) && new Date(r.due_date) < new Date() ? theme.danger : theme.muted) },
|
|
598
|
+
{ key: 'amount', title: 'Amount', width: 11, align: 'right', render: (r) => money(r.amount, r.currency || 'USD') },
|
|
599
|
+
{ key: 'status', title: 'Status', width: 9, color: (r) => statusColor(theme, r.status) },
|
|
600
|
+
...(p.width >= 65 ? [{ key: 'clients', title: 'Client', min: 8, max: 28, render: (r) => r.clients?.name || r.clients?.email || '—' }] : []),
|
|
601
|
+
...(p.width >= 100 ? [
|
|
602
|
+
{ key: 'sent_at', title: 'Sent', width: 10, render: (r) => shortDate(r.sent_at), color: theme.muted },
|
|
603
|
+
{ key: 'paid_at', title: 'Paid', width: 10, render: (r) => shortDate(r.paid_at), color: theme.success },
|
|
604
|
+
] : []),
|
|
605
|
+
...(p.width >= 135 ? [{ key: 'businesses', title: 'Business', min: 10, max: 26, render: (r) => r.businesses?.name || '—', color: theme.muted }] : []),
|
|
606
|
+
]);
|
|
607
|
+
});
|
|
608
|
+
col.panel({
|
|
609
|
+
title: `Escrow (${escrows.length})`,
|
|
610
|
+
subtitle: `held ${money(s.escrow.heldUsd)} · released ${money(s.escrow.releasedUsd)} · refunded ${money(s.escrow.refundedUsd)}`,
|
|
611
|
+
footer: 'click row for details · j/k navigate',
|
|
612
|
+
}, (p) => {
|
|
613
|
+
if (!escrows.length) { p.text('No escrows.', { fg: theme.muted }); return; }
|
|
614
|
+
selectableTable(p, state, 'escrows', escrows, [
|
|
615
|
+
{ key: 'created_at', title: 'Created', width: 10, render: (r) => shortDate(r.created_at), color: theme.muted },
|
|
616
|
+
{ key: 'chain', title: 'Chain', width: 8 },
|
|
617
|
+
{ key: 'amount_usd', title: 'USD', width: 10, align: 'right', render: (r) => money(r.amount_usd) },
|
|
618
|
+
{ key: 'status', title: 'Status', width: 10, color: (r) => statusColor(theme, r.status) },
|
|
619
|
+
{ key: 'metadata', title: 'Description', min: 8, max: 50, render: (r) => String(r.metadata?.description || r.beneficiary_email || ''), color: theme.muted },
|
|
620
|
+
...(p.width >= 100 ? [{ key: 'settled_at', title: 'Settled', width: 10, render: (r) => shortDate(r.settled_at || r.released_at || r.refunded_at), color: theme.muted }] : []),
|
|
621
|
+
]);
|
|
622
|
+
});
|
|
543
623
|
});
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
624
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
625
|
+
if (state.invoicePane === 'escrows') {
|
|
626
|
+
const row = escrows[esc.selected];
|
|
627
|
+
selectedDetails(col, 'Selected escrow', row, [
|
|
628
|
+
['Created', detailDateTime(row?.created_at)],
|
|
629
|
+
['Status', row?.status, row ? statusColor(theme, row.status) : undefined],
|
|
630
|
+
['Chain', row?.chain],
|
|
631
|
+
['USD amount', row ? money(row.amount_usd) : null],
|
|
632
|
+
['Crypto amount', row?.amount],
|
|
633
|
+
['Fee amount', row?.fee_amount],
|
|
634
|
+
['Description', row?.metadata?.description || row?.beneficiary_email],
|
|
635
|
+
['Settled', detailDateTime(row?.settled_at || row?.released_at || row?.refunded_at)],
|
|
636
|
+
['Escrow ID', row?.id],
|
|
637
|
+
], theme);
|
|
638
|
+
} else {
|
|
639
|
+
const row = invoices[inv.selected];
|
|
640
|
+
selectedDetails(col, 'Selected invoice', row, [
|
|
641
|
+
['Invoice', row?.invoice_number || row?.id],
|
|
642
|
+
['Created', detailDateTime(row?.created_at)],
|
|
643
|
+
['Due', shortDate(row?.due_date)],
|
|
644
|
+
['Sent', detailDateTime(row?.sent_at)],
|
|
645
|
+
['Paid', detailDateTime(row?.paid_at)],
|
|
646
|
+
['Client', row?.clients?.name],
|
|
647
|
+
['Email', row?.clients?.email],
|
|
648
|
+
['Business', row?.businesses?.name],
|
|
649
|
+
['Amount', row ? money(row.amount, row.currency || 'USD') : null],
|
|
650
|
+
['Status', row?.status, row ? statusColor(theme, row.status) : undefined],
|
|
651
|
+
['Settles in', row?.settlement_method || row?.crypto_currency],
|
|
652
|
+
['Notes', row?.notes],
|
|
653
|
+
['Invoice ID', row?.id],
|
|
654
|
+
], theme);
|
|
655
|
+
}
|
|
570
656
|
});
|
|
571
657
|
});
|
|
572
658
|
}
|
|
@@ -611,7 +697,7 @@ function positionScreen(ui, state, theme) {
|
|
|
611
697
|
const debt = p0.debt;
|
|
612
698
|
const r = p0.ratios;
|
|
613
699
|
|
|
614
|
-
ui.grid({ columns: ['1fr', '1fr', '1fr'], rows: [13, '1fr'
|
|
700
|
+
ui.grid({ columns: ['1fr', '1fr', '1fr'], rows: [13, 8, '1fr'], gap: 1 }, (grid) => {
|
|
615
701
|
grid.panel({ title: `Income vs spending · ${lookback}`, subtitle: `${p0.monthsObserved} months observed` }, (p) => {
|
|
616
702
|
p.keyValues([
|
|
617
703
|
{ label: 'Income', value: money(inc.total, cur), color: theme.success },
|
|
@@ -687,16 +773,7 @@ function positionScreen(ui, state, theme) {
|
|
|
687
773
|
p.text('No debt on any linked account.', { fg: theme.muted });
|
|
688
774
|
return;
|
|
689
775
|
}
|
|
690
|
-
|
|
691
|
-
p.table({
|
|
692
|
-
rows: debt.accounts,
|
|
693
|
-
selected: debts.selected,
|
|
694
|
-
offset: debts.offset,
|
|
695
|
-
followSelection: true,
|
|
696
|
-
scrollbar: true,
|
|
697
|
-
onScroll: (delta) => scrollPane(debts, delta),
|
|
698
|
-
onSelectRow: (row) => { debts.selected = debts.offset + row; },
|
|
699
|
-
columns: [
|
|
776
|
+
selectableTable(p, state, 'debts', debt.accounts, [
|
|
700
777
|
{ key: 'org', title: 'Institution', width: 20, render: (x) => x.org || '—', color: theme.muted },
|
|
701
778
|
{ key: 'name', title: 'Account', min: 14, max: 32 },
|
|
702
779
|
{ key: 'kind', title: 'Kind', width: 8, color: theme.muted },
|
|
@@ -705,27 +782,40 @@ function positionScreen(ui, state, theme) {
|
|
|
705
782
|
{ key: 'share', title: 'Share', width: 6, align: 'right', render: (x) => pct(x.share, 0), color: theme.muted },
|
|
706
783
|
{ key: 'paid', title: `Paid ${lookback}`, width: 12, align: 'right', render: (x) => money(x.paid, cur), color: theme.success },
|
|
707
784
|
{ 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) },
|
|
708
|
-
|
|
709
|
-
});
|
|
785
|
+
]);
|
|
710
786
|
});
|
|
711
787
|
|
|
712
|
-
grid.
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
788
|
+
grid.cell({ gap: 1 }, (col) => {
|
|
789
|
+
const selectedDebt = debt.accounts[pane(state, 'debts', debt.accounts.length).selected];
|
|
790
|
+
selectedDetails(col, 'Selected debt', selectedDebt, [
|
|
791
|
+
['Institution', selectedDebt?.org],
|
|
792
|
+
['Account', selectedDebt?.name],
|
|
793
|
+
['Kind', selectedDebt?.kind],
|
|
794
|
+
['Side', selectedDebt?.scope],
|
|
795
|
+
['Owed', selectedDebt ? money(selectedDebt.owed, cur) : null, theme.danger],
|
|
796
|
+
[`Paid ${lookback}`, selectedDebt ? money(selectedDebt.paid, cur) : null, theme.success],
|
|
797
|
+
['Payoff', selectedDebt?.payoffMonths == null ? '—' : `${selectedDebt.payoffMonths} months`],
|
|
798
|
+
], theme, {
|
|
799
|
+
after: (p) => {
|
|
800
|
+
p.divider({ label: `Recurring · ${money(p0.recurring.monthlyTotal, cur)}/mo` });
|
|
801
|
+
const charges = p0.recurring.charges;
|
|
802
|
+
if (!charges.length) {
|
|
803
|
+
p.text('Nothing recurring found in this window.', { fg: theme.muted });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const max = Math.max(1, ...charges.map((c) => c.monthlyEquivalent));
|
|
807
|
+
p.meters(
|
|
808
|
+
charges.slice(0, 10).map((c) => ({
|
|
809
|
+
label: c.payee.slice(0, 16),
|
|
810
|
+
value: c.monthlyEquivalent,
|
|
811
|
+
max,
|
|
812
|
+
color: c.isDebtService ? theme.warning : theme.info,
|
|
813
|
+
text: `${money(c.monthlyEquivalent, cur, { compact: true })}/mo`,
|
|
814
|
+
})),
|
|
815
|
+
{ labelWidth: 17, valueWidth: 9 },
|
|
816
|
+
);
|
|
817
|
+
},
|
|
818
|
+
});
|
|
729
819
|
});
|
|
730
820
|
});
|
|
731
821
|
}
|
|
@@ -824,7 +914,8 @@ export function renderFinancesTui(ui, state, t, { height, interval = 30, markdow
|
|
|
824
914
|
' by the bank bridge, so it is never automatic).\n' +
|
|
825
915
|
'w cycles the window: 7 → 30 → 90 → 365 days.\n' +
|
|
826
916
|
'p pauses the timer. ↑/↓ j/k, PgUp/PgDn, Home/End scroll.\n' +
|
|
827
|
-
'Mouse: click
|
|
917
|
+
'Mouse: click rows for details on the right; scroll tables.\n' +
|
|
918
|
+
'j/k follows the last invoice/escrow table clicked.\n\n' +
|
|
828
919
|
'⧉ MD copies a summary. Tab focuses, Enter copies.\n\n' +
|
|
829
920
|
'Commission paid = platform fees on crypto + card payments.\n' +
|
|
830
921
|
'Net earnings = gross − commission − processor fees − refunds.\n' +
|
|
@@ -971,6 +1062,7 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
|
|
|
971
1062
|
return;
|
|
972
1063
|
}
|
|
973
1064
|
const name = event.name;
|
|
1065
|
+
const paneName = activePane(state);
|
|
974
1066
|
const digit = Number(name);
|
|
975
1067
|
if (Number.isInteger(digit) && name.length === 1 && digit >= 1 && digit <= TABS.length) {
|
|
976
1068
|
state.tab = digit - 1;
|
|
@@ -1006,25 +1098,25 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
|
|
|
1006
1098
|
break;
|
|
1007
1099
|
case 'up':
|
|
1008
1100
|
case 'k':
|
|
1009
|
-
moveSelection(pane(state,
|
|
1101
|
+
moveSelection(pane(state, paneName, state.panes[paneName]?.total ?? 0), state.tab === 0 ? 1 : -1);
|
|
1010
1102
|
break;
|
|
1011
1103
|
case 'down':
|
|
1012
1104
|
case 'j':
|
|
1013
|
-
moveSelection(pane(state,
|
|
1105
|
+
moveSelection(pane(state, paneName, state.panes[paneName]?.total ?? 0), state.tab === 0 ? -1 : 1);
|
|
1014
1106
|
break;
|
|
1015
1107
|
case 'pageup':
|
|
1016
|
-
scrollPane(pane(state,
|
|
1108
|
+
scrollPane(pane(state, paneName, state.panes[paneName]?.total ?? 0), state.tab === 0 ? 1 : -1, 10);
|
|
1017
1109
|
break;
|
|
1018
1110
|
case 'pagedown':
|
|
1019
|
-
scrollPane(pane(state,
|
|
1111
|
+
scrollPane(pane(state, paneName, state.panes[paneName]?.total ?? 0), state.tab === 0 ? -1 : 1, 10);
|
|
1020
1112
|
break;
|
|
1021
1113
|
case 'home': {
|
|
1022
|
-
const p = pane(state,
|
|
1114
|
+
const p = pane(state, paneName, state.panes[paneName]?.total ?? 0);
|
|
1023
1115
|
p.selected = 0; p.offset = state.tab === 0 ? Math.max(0, p.total - 1) : 0;
|
|
1024
1116
|
break;
|
|
1025
1117
|
}
|
|
1026
1118
|
case 'end': {
|
|
1027
|
-
const p = pane(state,
|
|
1119
|
+
const p = pane(state, paneName, state.panes[paneName]?.total ?? 0);
|
|
1028
1120
|
p.selected = Math.max(0, p.total - 1); p.offset = state.tab === 0 ? 0 : p.selected;
|
|
1029
1121
|
break;
|
|
1030
1122
|
}
|