@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.
@@ -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
- 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: [
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
- ui.grid({ columns: ['3fr', '1fr'], gap: 1 }, (grid) => {
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
- const ledger = pane(state, 'ledger', b.ledger.length);
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
- ui.grid({ columns: ['3fr', '1fr'], gap: 1 }, (grid) => {
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
- const payments = pane(state, 'payments', rows.length);
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
- ui.grid({ columns: ['3fr', '1fr'], gap: 1 }, (grid) => {
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
- const cards = pane(state, 'cards', rows.length);
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
- ui.panel({
519
- title: `Invoices (${invoices.length})`,
520
- subtitle: `outstanding ${money(s.invoices.totals.outstanding)} · overdue ${money(s.invoices.totals.overdue)} · paid ${money(s.invoices.totals.paid)}`,
521
- size: '55%',
522
- }, (p) => {
523
- if (!invoices.length) { p.text('No invoices.', { fg: theme.muted }); return; }
524
- const inv = pane(state, 'invoices', invoices.length);
525
- p.table({
526
- rows: invoices,
527
- selected: inv.selected,
528
- offset: inv.offset,
529
- followSelection: true,
530
- scrollbar: true,
531
- onScroll: (delta) => scrollPane(inv, delta),
532
- onSelectRow: (row) => { inv.selected = inv.offset + row; },
533
- columns: [
534
- { key: 'invoice_number', title: 'No.', width: 9 },
535
- { key: 'clients', title: 'Client', min: 10, max: 28, render: (r) => r.clients?.name || r.clients?.email || '—' },
536
- { key: 'businesses', title: 'Business', min: 10, max: 26, render: (r) => r.businesses?.name || '—', color: theme.muted },
537
- { key: 'amount', title: 'Amount', width: 11, align: 'right', render: (r) => money(r.amount, r.currency || 'USD') },
538
- { key: 'status', title: 'Status', width: 10, color: (r) => statusColor(theme, r.status) },
539
- { 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) },
540
- { key: 'paid_at', title: 'Paid', width: 10, render: (r) => shortDate(r.paid_at), color: theme.success },
541
- { key: 'crypto_currency', title: 'Settles in', width: 10, render: (r) => r.settlement_method || r.crypto_currency || '—', color: theme.muted },
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
- ui.spacer(1);
546
- ui.panel({
547
- title: `Escrow (${escrows.length})`,
548
- subtitle: `held ${money(s.escrow.heldUsd)} · released ${money(s.escrow.releasedUsd)} · refunded ${money(s.escrow.refundedUsd)}`,
549
- }, (p) => {
550
- if (!escrows.length) { p.text('No escrows.', { fg: theme.muted }); return; }
551
- const esc = pane(state, 'escrows', escrows.length);
552
- p.table({
553
- rows: escrows,
554
- selected: esc.selected,
555
- offset: esc.offset,
556
- followSelection: true,
557
- scrollbar: true,
558
- onScroll: (delta) => scrollPane(esc, delta),
559
- onSelectRow: (row) => { esc.selected = esc.offset + row; },
560
- columns: [
561
- { key: 'created_at', title: 'Created', width: 10, render: (r) => shortDate(r.created_at), color: theme.muted },
562
- { key: 'chain', title: 'Chain', width: 9 },
563
- { key: 'amount_usd', title: 'USD', width: 10, align: 'right', render: (r) => money(r.amount_usd) },
564
- { key: 'amount', title: 'Amount', width: 13, align: 'right', render: (r) => num(r.amount).toFixed(6), color: theme.muted },
565
- { 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 },
566
- { key: 'status', title: 'Status', width: 10, color: (r) => statusColor(theme, r.status) },
567
- { key: 'metadata', title: 'Description', min: 10, max: 50, render: (r) => String(r.metadata?.description || r.beneficiary_email || '').slice(0, 60), color: theme.muted },
568
- { key: 'settled_at', title: 'Settled', width: 10, render: (r) => shortDate(r.settled_at || r.released_at || r.refunded_at), color: theme.muted },
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', 12], gap: 1 }, (grid) => {
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
- const debts = pane(state, 'debts', debt.accounts.length);
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.panel({ title: `Recurring (${p0.recurring.charges.length})`, subtitle: `${money(p0.recurring.monthlyTotal, cur)}/mo` }, (p) => {
713
- const charges = p0.recurring.charges;
714
- if (!charges.length) {
715
- p.text('Nothing recurring found in this window.', { fg: theme.muted });
716
- return;
717
- }
718
- const max = Math.max(1, ...charges.map((c) => c.monthlyEquivalent));
719
- p.meters(
720
- charges.slice(0, 10).map((c) => ({
721
- label: c.payee.slice(0, 16),
722
- value: c.monthlyEquivalent,
723
- max,
724
- color: c.isDebtService ? theme.warning : theme.info,
725
- text: `${money(c.monthlyEquivalent, cur, { compact: true })}/mo`,
726
- })),
727
- { labelWidth: 17, valueWidth: 9 },
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 tabs, scroll tables.\n\n' +
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, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? 1 : -1);
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, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? -1 : 1);
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, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? 1 : -1, 10);
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, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0), state.tab === 0 ? -1 : 1, 10);
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, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0);
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, TAB_PANE[state.tab], state.panes[TAB_PANE[state.tab]]?.total ?? 0);
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
  }