@profullstack/coinpay 0.11.0 → 0.11.2

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 CHANGED
@@ -5,6 +5,18 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.11.2] - 2026-09-16
9
+
10
+ ### Added
11
+ - Invoice creation and due dates remain visible in the finance TUI table;
12
+ selected invoices show created, due, sent and paid dates in the detail panel.
13
+ - Right-side details for selected accounts, ledger rows, crypto and card
14
+ payments, invoices, escrows and debt accounts.
15
+
16
+ ### Fixed
17
+ - Row clicks select the correct record after scrolling or resizing. Keyboard
18
+ navigation follows the last selected invoice or escrow table.
19
+
8
20
  ## [0.11.0] - 2026-09-12
9
21
 
10
22
  ### Added
package/README.md CHANGED
@@ -704,7 +704,7 @@ coinpay finances connections # institutions and their last sync
704
704
  coinpay finances sync # pull fresh balances (rate-limited by the bank bridge)
705
705
  ```
706
706
 
707
- The dashboard has seven screens (`1`–`7`, `Tab`): **Overview** (gross volume, crypto vs
707
+ The dashboard has seven screens (`1`–`7`, `←`/`→`): **Overview** (gross volume, crypto vs
708
708
  cards, commission paid, processor fees, refunds, net earnings, bank position, cashflow,
709
709
  invoices, escrow, payouts, a volume-vs-commission graph and a live feed), **Bank &
710
710
  Cards**, **Ledger**, **Crypto**, **Cards**, **Invoices & Escrow**, **Debt & Income**.
@@ -713,6 +713,13 @@ syncs the bank feed, `w` cycles the window, `p` pauses, `?` shows help, `q` quit
713
713
  refreshes every 30 seconds (`--interval`) and listens to the payments event stream, so
714
714
  a crypto payment shows up the moment it is detected.
715
715
 
716
+ Click **⧉ MD** on a summary pane to copy Markdown. The header copy button exports
717
+ status, including bank connection health and detailed source errors. Each pane
718
+ carries its displayed window, snapshot timestamp, stream status and partial-data
719
+ warnings. Transaction rows and the live event log are excluded. Tab / Shift+Tab
720
+ focus controls; Enter / Space copies the focused summary. Clipboard writes use
721
+ OSC 52 and require terminal support, including when connected over SSH.
722
+
716
723
  **Debt & Income** is the basic-accounting view, and it reads the same for a company or
717
724
  a person: income against spending per month, total owed split into revolving and
718
725
  instalment, months to clear each balance at its current payment rate, debt-to-income,
package/bin/coinpay.js CHANGED
@@ -173,6 +173,7 @@ const BOOLEAN_FLAGS = new Set([
173
173
  'no-model',
174
174
  'only-uncategorized',
175
175
  'no-attach',
176
+ 'estimate-gaps',
176
177
  'plain',
177
178
  'no-stream',
178
179
  'hidden',
@@ -477,7 +478,7 @@ ${colors.cyan}Commands:${colors.reset}
477
478
  coverage What part of a period was ever fetched (--period, --account)
478
479
  report Monthly/quarterly/custom activity report (--period 2026-08 |
479
480
  --from/--to; --account, --scope, --timezone, --format pdf|csv|html|json,
480
- --output <file>, --strict, --wait, --json)
481
+ --output <file>, --strict, --estimate-gaps, --wait, --json)
481
482
  reports [get|download|delete <id>] Report history and downloads
482
483
  statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
483
484
  statements list|get|download|reconcile|delete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/coinpay",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -139,7 +139,7 @@
139
139
  "@inquirer/prompts": "^8.3.0",
140
140
  "@noble/curves": "^1.6.0",
141
141
  "@noble/hashes": "^1.5.0",
142
- "@profullstack/hqtui": "^0.1.11",
142
+ "@profullstack/hqtui": "^0.6.3",
143
143
  "@scure/bip32": "^1.5.0",
144
144
  "@scure/bip39": "^1.4.0"
145
145
  }
@@ -247,6 +247,7 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
247
247
  includePendingAppendix: flags['no-pending'] ? false : true,
248
248
  formats: [format, 'json'],
249
249
  strict,
250
+ estimateGaps: flags['estimate-gaps'] === true,
250
251
  idempotencyKey: typeof flags['idempotency-key'] === 'string' ? flags['idempotency-key'] : undefined,
251
252
  });
252
253
  let report = created.report;
@@ -196,6 +196,8 @@ export function createFinanceReport(
196
196
  includePendingAppendix?: boolean;
197
197
  formats?: ReportFormat[];
198
198
  strict?: boolean;
199
+ /** Fill the days before the first observed posting with a labelled extrapolation. */
200
+ estimateGaps?: boolean;
199
201
  idempotencyKey?: string;
200
202
  },
201
203
  ): Promise<{ report: FinanceReport; job: FinanceJob }>;
@@ -156,12 +156,12 @@ export async function getFinanceCoverage(client, { period, from, to, timezone, a
156
156
  * use `waitForFinanceReport` to block until it is ready.
157
157
  */
158
158
  export async function createFinanceReport(client, {
159
- period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, idempotencyKey,
159
+ period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, estimateGaps, idempotencyKey,
160
160
  } = {}) {
161
161
  return call(client, '/finances/reports', {
162
162
  method: 'POST',
163
163
  headers: headersWith(idempotencyKey),
164
- body: JSON.stringify({ period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict }),
164
+ body: JSON.stringify({ period, from, to, timezone, accountIds, scope, includeHidden, includePendingAppendix, formats, strict, estimateGaps }),
165
165
  });
166
166
  }
167
167
 
@@ -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();
@@ -95,7 +101,7 @@ function num(v) {
95
101
 
96
102
  // ── State ──
97
103
 
98
- function createState(days) {
104
+ export function createState(days) {
99
105
  return {
100
106
  tab: 0,
101
107
  days,
@@ -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,33 +782,152 @@ 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
  }
732
822
 
733
823
  const SCREENS = [overviewScreen, bankScreen, ledgerScreen, cryptoScreen, cardsScreen, invoicesScreen, positionScreen];
734
824
 
825
+ /** Context exported with each pane; the displayed snapshot can lag the requested window. */
826
+ export function financeMarkdownContext(state) {
827
+ const s = state.snapshot;
828
+ return [
829
+ `CoinPay Finances · ${TABS[state.tab]}`,
830
+ `Payment business filter: ${state.businessId || 'all businesses'}; bank data is merchant-wide`,
831
+ `Displayed window: ${s?.windowDays ?? state.days} days`,
832
+ ...(s && s.windowDays !== state.days ? [`Requested window: ${state.days} days (refresh pending)`] : []),
833
+ `Snapshot: ${s?.generatedAt || 'not loaded'}`,
834
+ `Updates: ${state.loading ? 'refreshing' : state.paused ? 'paused' : 'live'}`,
835
+ `Payment stream: ${state.liveStatus}`,
836
+ ...(state.error ? [`Refresh failed: ${state.error}`] : []),
837
+ ...Object.entries(s?.errors || {}).map(([source, error]) => `Source unavailable — ${source}: ${error}`),
838
+ ...(s?.crypto.partial ? ['Crypto details cover a partial page.'] : []),
839
+ ...(s?.card.partial ? ['Card details, processor fees and refunds cover a partial page.'] : []),
840
+ ...(s?.position ? [`Debt & Income: ${s.position.observedDays ?? s.position.lookbackDays} days observed; independent of the dashboard window.`] : []),
841
+ ].join('\n');
842
+ }
843
+
844
+ /** Status can be copied without including any live payment or transaction rows. */
845
+ export function financeStatusMarkdown(state, markdownText) {
846
+ return [
847
+ '## CoinPay status',
848
+ markdownText(financeMarkdownContext(state)),
849
+ `Bank sync: ${state.syncing ? 'running' : 'idle'}`,
850
+ ...(state.notice ? [markdownText(state.notice)] : []),
851
+ ...(state.snapshot?.bank.connections || []).map((c) => markdownText(
852
+ `${c.label || c.provider || 'Bank connection'}: ${c.is_active ? 'active' : 'inactive'}; last sync ${c.last_synced_at || 'never'} (${c.last_sync_status || 'unknown'})${c.last_sync_error ? `; ${c.last_sync_error}` : ''}`,
853
+ )),
854
+ ].join('\n\n') + '\n';
855
+ }
856
+
857
+ /** Draw the same summary controls in production and headless interaction tests. */
858
+ export function renderFinancesTui(ui, state, t, { height, interval = 30, markdownText }) {
859
+ ui.row({ size: 1 }, (header) => {
860
+ header.text(' CoinPay ', { fg: t.title, bold: true, size: 10 });
861
+ header.tabs({
862
+ tabs: TABS.map((name, i) => `${i + 1} ${name}`),
863
+ active: state.tab,
864
+ onSelect: (index) => { state.tab = index; },
865
+ });
866
+ const right = [
867
+ state.paused ? 'paused' : state.loading ? 'loading…' : `${state.days}d`,
868
+ state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : 'starting',
869
+ clock(),
870
+ ].join(' ');
871
+ header.text(`${right} `, { fg: state.paused ? t.warning : state.error ? t.danger : t.success, align: 'right' });
872
+ header.copyButton({ markdown: () => financeStatusMarkdown(state, markdownText), width: 6 });
873
+ });
874
+ ui.spacer(1);
875
+
876
+ ui.column({ size: height - 4 }, (body) => {
877
+ if (!state.snapshot) {
878
+ body.panel({ title: 'Finances' }, (p) => {
879
+ p.text(state.error ? `Could not load: ${state.error}` : 'Loading your numbers…', { fg: state.error ? t.danger : t.muted });
880
+ if (state.error) p.text('Press r to retry, q to quit.', { fg: t.muted });
881
+ });
882
+ return;
883
+ }
884
+ SCREENS[state.tab](body, state, t);
885
+ });
886
+
887
+ ui.spacer(1);
888
+ const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0;
889
+ ui.statusBar({
890
+ items: [
891
+ { key: '1-7', label: 'Screen' },
892
+ { key: 'r', label: 'Refresh' },
893
+ { key: 's', label: state.syncing ? 'Syncing…' : 'Sync bank', active: state.syncing },
894
+ { key: 'w', label: `Window ${state.days}d` },
895
+ { key: 'p', label: state.paused ? 'Resume' : 'Pause', active: state.paused },
896
+ { key: '?', label: 'Help' },
897
+ { key: 'q', label: 'Quit' },
898
+ ],
899
+ right: [
900
+ ...(errorCount ? [{ label: `${errorCount} source${errorCount > 1 ? 's' : ''} unavailable`, color: t.warning }] : []),
901
+ { label: state.liveStatus, color: state.liveStatus === 'stream connected' ? t.success : t.muted },
902
+ ],
903
+ });
904
+
905
+ if (state.showHelp) {
906
+ ui.modal({
907
+ title: 'CoinPay Finances — Help',
908
+ width: 66,
909
+ height: 22,
910
+ message:
911
+ '1-7, ←/→ switch screens.\n' +
912
+ 'r refreshes now; refresh also runs every ' + Math.max(5, interval) + 's.\n' +
913
+ 's pulls fresh bank balances and transactions (rate-limited\n' +
914
+ ' by the bank bridge, so it is never automatic).\n' +
915
+ 'w cycles the window: 7 → 30 → 90 → 365 days.\n' +
916
+ 'p pauses the timer. ↑/↓ j/k, PgUp/PgDn, Home/End scroll.\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' +
919
+ '⧉ MD copies a summary. Tab focuses, Enter copies.\n\n' +
920
+ 'Commission paid = platform fees on crypto + card payments.\n' +
921
+ 'Net earnings = gross − commission − processor fees − refunds.\n' +
922
+ 'Debt & Income covers 180 days regardless of w, and excludes\n' +
923
+ ' transfers and card payments from both sides.\n\n' +
924
+ 'Esc or Close dismisses help.',
925
+ buttons: [{ label: 'Close', onPress: () => { state.showHelp = false; } }],
926
+ onDismiss: () => { state.showHelp = false; },
927
+ });
928
+ }
929
+ }
930
+
735
931
  // ── App ──
736
932
 
737
933
  async function loadHqtui() {
@@ -755,8 +951,13 @@ async function loadHqtui() {
755
951
  */
756
952
  export async function runFinancesTui({ client, baseUrl, token, days = 30, interval = 30, businessId, limit = 100, theme } = {}) {
757
953
  const hqtui = await loadHqtui();
758
- const app = await hqtui.createApp({ fps: 30, theme: theme || 'dark', quitKeys: ['ctrl+c', 'q'] });
759
954
  const state = createState(days);
955
+ state.businessId = businessId;
956
+ const app = await hqtui.createApp({
957
+ fps: 30, theme: theme || 'dark', quitKeys: ['ctrl+c', 'q'],
958
+ copyMarkdown: true,
959
+ markdownContext: () => financeMarkdownContext(state),
960
+ });
760
961
 
761
962
  let refreshing = false;
762
963
  let refreshTimer = null;
@@ -861,13 +1062,13 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
861
1062
  return;
862
1063
  }
863
1064
  const name = event.name;
1065
+ const paneName = activePane(state);
864
1066
  const digit = Number(name);
865
1067
  if (Number.isInteger(digit) && name.length === 1 && digit >= 1 && digit <= TABS.length) {
866
1068
  state.tab = digit - 1;
867
1069
  return;
868
1070
  }
869
1071
  switch (name) {
870
- case 'tab':
871
1072
  case 'right':
872
1073
  case 'l':
873
1074
  state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length;
@@ -897,25 +1098,25 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
897
1098
  break;
898
1099
  case 'up':
899
1100
  case 'k':
900
- 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);
901
1102
  break;
902
1103
  case 'down':
903
1104
  case 'j':
904
- 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);
905
1106
  break;
906
1107
  case 'pageup':
907
- 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);
908
1109
  break;
909
1110
  case 'pagedown':
910
- 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);
911
1112
  break;
912
1113
  case 'home': {
913
- 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);
914
1115
  p.selected = 0; p.offset = state.tab === 0 ? Math.max(0, p.total - 1) : 0;
915
1116
  break;
916
1117
  }
917
1118
  case 'end': {
918
- 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);
919
1120
  p.selected = Math.max(0, p.total - 1); p.offset = state.tab === 0 ? 0 : p.selected;
920
1121
  break;
921
1122
  }
@@ -925,74 +1126,7 @@ export async function runFinancesTui({ client, baseUrl, token, days = 30, interv
925
1126
  app.invalidate();
926
1127
  });
927
1128
 
928
- app.render(({ ui, theme: t, height }) => {
929
- ui.row({ size: 1 }, (header) => {
930
- header.text(' CoinPay ', { fg: t.title, bold: true, size: 10 });
931
- header.tabs({
932
- tabs: TABS.map((name, i) => `${i + 1} ${name}`),
933
- active: state.tab,
934
- onSelect: (index) => { state.tab = index; },
935
- });
936
- const right = [
937
- state.paused ? 'paused' : state.loading ? 'loading…' : `${state.days}d`,
938
- state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : 'starting',
939
- clock(),
940
- ].join(' ');
941
- header.text(`${right} `, { fg: state.paused ? t.warning : state.error ? t.danger : t.success, align: 'right' });
942
- });
943
- ui.spacer(1);
944
-
945
- ui.column({ size: height - 4 }, (body) => {
946
- if (!state.snapshot) {
947
- body.panel({ title: 'Finances' }, (p) => {
948
- p.text(state.error ? `Could not load: ${state.error}` : 'Loading your numbers…', { fg: state.error ? t.danger : t.muted });
949
- if (state.error) p.text('Press r to retry, q to quit.', { fg: t.muted });
950
- });
951
- return;
952
- }
953
- SCREENS[state.tab](body, state, t);
954
- });
955
-
956
- ui.spacer(1);
957
- const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0;
958
- ui.statusBar({
959
- items: [
960
- { key: '1-7', label: 'Screen' },
961
- { key: 'r', label: 'Refresh' },
962
- { key: 's', label: state.syncing ? 'Syncing…' : 'Sync bank', active: state.syncing },
963
- { key: 'w', label: `Window ${state.days}d` },
964
- { key: 'p', label: state.paused ? 'Resume' : 'Pause', active: state.paused },
965
- { key: '?', label: 'Help' },
966
- { key: 'q', label: 'Quit' },
967
- ],
968
- right: [
969
- ...(errorCount ? [{ label: `${errorCount} source${errorCount > 1 ? 's' : ''} unavailable`, color: t.warning }] : []),
970
- { label: state.liveStatus, color: state.liveStatus === 'stream connected' ? t.success : t.muted },
971
- ],
972
- });
973
-
974
- if (state.showHelp) {
975
- ui.modal({
976
- title: 'CoinPay Finances — Help',
977
- width: 66,
978
- height: 20,
979
- message:
980
- '1-7, Tab, ←/→ switch screens.\n' +
981
- 'r refreshes now; refresh also runs every ' + Math.max(5, interval) + 's.\n' +
982
- 's pulls fresh bank balances and transactions (rate-limited\n' +
983
- ' by the bank bridge, so it is never automatic).\n' +
984
- 'w cycles the window: 7 → 30 → 90 → 365 days.\n' +
985
- 'p pauses the timer. ↑/↓ j/k, PgUp/PgDn, Home/End scroll.\n' +
986
- 'Mouse: click tabs, scroll tables.\n\n' +
987
- 'Commission paid = platform fees on crypto + card payments.\n' +
988
- 'Net earnings = gross − commission − processor fees − refunds.\n' +
989
- 'Debt & Income covers 180 days regardless of w, and excludes\n' +
990
- ' transfers and card payments from both sides.\n\n' +
991
- 'Press any key to close.',
992
- buttons: [{ label: 'Close', focused: true }],
993
- });
994
- }
995
- });
1129
+ app.render(({ ui, theme: t, height }) => renderFinancesTui(ui, state, t, { height, interval, markdownText: hqtui.markdownText }));
996
1130
 
997
1131
  app.on('exit', () => {
998
1132
  clearInterval(poll);