From 4fcfbef28ee9f53425f552b69e99422096d8dfcb Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 14:44:33 -0700 Subject: Settings → Wallet → Transaction history: Export CSV — every payment in Spending and Savings as a line of a CSV file, oldest first: date (UTC), account, type, rail (Lightning, Ark, on-chain), what was paid, its fee, the change to the balance and its worth in your currency at the time, status, the transaction or payment hash, who it went to, the invoice. A failed payment is left out; a renewal is its fee. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/app.js | 42 +++++++++++++++++++++++++++++++++++++++++- src/features/ark.js | 23 +++++++++++++++++++++-- src/i18n.js | 3 +++ 3 files changed, 65 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 416fd59..81718b1 100644 --- a/src/app.js +++ b/src/app.js @@ -2974,7 +2974,7 @@ function settingsTab() { switch (ui.settingsPage) { // the recovery phrase first: it is the one thing on this page that // matters if the phone is lost tonight - case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a)] : []); + case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []); case 'payments': return page(featureAll('settingsCards').reverse()); case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now @@ -3631,6 +3631,46 @@ function pubkeyCard(a) { ); } +// Every payment in both balances as a CSV file, for a spreadsheet or an +// accountant: Savings' on-chain transactions and Spending's movements (the +// rows the features list in History, each with its `csv` line), oldest +// first, priced in the chosen currency at the time each happened. +function historyCsv() { + const cur = getCurrency(); + const lines = []; + for (const tx of wallet.history || []) { + const out = tx.net < 0, fee = out ? tx.fee || 0 : 0; + lines.push({ ts: tx.confirmed && tx.blockTime ? tx.blockTime * 1000 : Date.now(), account: t('savingLabel'), type: out ? 'send' : 'receive', rail: 'onchain', + amount: out ? tx.net + fee : tx.net, fee, net: tx.net, status: tx.confirmed ? 'complete' : 'pending', ref: tx.txid, to: '', invoice: '', note: '' }); + } + for (const [sel, account] of [['savings', t('savingLabel')], ['spending', t('spendingLabel')]]) + for (const e of featureAll('historyEntries', wallet.history || [], sel)) if (e.csv) lines.push({ ...e.csv, account }); + lines.sort((a, b) => a.ts - b.ts); + const q = (v) => (v == null ? '' : /[",\n\r]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v)); + const head = ['Date (UTC)', 'Account', 'Type', 'Rail', 'Amount (sats)', 'Fee (sats)', 'Net (sats)', `Net (${cur} at the time)`, 'Status', 'Transaction / payment hash', 'To', 'Invoice', 'Note']; + const rows = lines.map((l) => { + const { rate, stale } = rateAt(l.ts / 1000); + const fiat = rate && !stale ? ((l.net / 1e8) * rate).toFixed(2) : ''; + return [new Date(l.ts).toISOString().replace('T', ' ').slice(0, 19), l.account, l.type, l.rail, l.amount, l.fee || 0, l.net, fiat, l.status, l.ref, l.to, l.invoice, l.note]; + }); + return [head, ...rows].map((r) => r.map(q).join(',')).join('\r\n') + '\r\n'; +} +function exportHistory() { + const name = `coinos-history-${new Date().toISOString().slice(0, 10)}.csv`; + const url = URL.createObjectURL(new Blob([historyCsv()], { type: 'text/csv;charset=utf-8' })); + const a = h('a', { href: url, download: name, style: 'display:none' }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} +function historyExportCard() { + return h('div', { class: 'card col' }, + h('h3', {}, t('historyExportTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('historyExportDesc')), + h('button', { class: 'btn-block history-export', disabled: !wallet.loaded, onClick: exportHistory }, t('historyExportBtn'))); +} + function autolockCard(a) { return h('div', { class: 'card col' }, h('h3', {}, t('autolockTitle')), diff --git a/src/features/ark.js b/src/features/ark.js index 1619ce4..abb297a 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1430,6 +1430,25 @@ export function arkFeature(ctx) { ); } + // a movement as a line of the history export (Settings → Wallet): what + // was paid, the fee beside it, and the change to the balance — a failed + // payment moved nothing; a renewal moved nothing but its fee + function arkCsvRow(m) { + if (m.status === 'failed') return null; + const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type); + const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0; + const amount = m.type === 'refresh' ? 0 : m.amountSat || 0; + const viaLn = m.type.startsWith('ln-') || m.viaLn; + return { + ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type, + rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark', + amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount, + status: m.status === 'complete' ? 'complete' : 'pending', + ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '', + note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '', + }; + } + const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now(); // A Lightning payment (a zap, mostly) still settling: the money has left // the balance, so history shows it now as the Sent row it will become. @@ -3956,7 +3975,7 @@ export function arkFeature(ctx) { const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash)); const lnPending = (s.actions || []) .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash)) - .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; }); + .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; }); // Same-payment duplicates (the old settle race, or a snapshot adopted // whole before the merge learned to dedupe) fold at display time too — // the earliest telling wins, everywhere, however the state got here. @@ -4009,7 +4028,7 @@ export function arkFeature(ctx) { out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] }); } } - return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }))); + return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }))); }, historyDetail() { if (ui.arkExitDetail) { diff --git a/src/i18n.js b/src/i18n.js index db290bb..fa21732 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -224,6 +224,9 @@ const STR = { moveToSavingDesc: 'Park money in ordinary on-chain Bitcoin.', moveToSavingBtn: 'Move to Savings', spendingLabel: 'Spending', + historyExportTitle: 'Transaction history', + historyExportDesc: 'Download every payment in Spending and Savings as a CSV file, for a spreadsheet or your accountant.', + historyExportBtn: 'Export CSV', savingLabel: 'Savings', balanceSwitch: 'Switch to {name}', movingLabel: 'Moving…', -- cgit v1.2.3