@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 +12 -0
- package/README.md +8 -1
- package/bin/coinpay.js +2 -1
- package/package.json +2 -2
- package/src/finances-commands.js +1 -0
- package/src/finances-reports.d.ts +2 -0
- package/src/finances-reports.js +2 -2
- package/src/finances-tui.js +344 -210
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`,
|
|
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.
|
|
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.
|
|
142
|
+
"@profullstack/hqtui": "^0.6.3",
|
|
143
143
|
"@scure/bip32": "^1.5.0",
|
|
144
144
|
"@scure/bip39": "^1.4.0"
|
|
145
145
|
}
|
package/src/finances-commands.js
CHANGED
|
@@ -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 }>;
|
package/src/finances-reports.js
CHANGED
|
@@ -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
|
|
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();
|
|
@@ -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
|
-
|
|
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,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.
|
|
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
|
}
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|