aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js42
-rw-r--r--src/features/ark.js23
-rw-r--r--src/i18n.js3
3 files changed, 65 insertions, 3 deletions
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…',