Files
AtreidesAssetsFree/client/pages/accounts.js
T

378 lines
19 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 15:24:06 +01:00
// Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8
2026-10-29 11:57:29 +01:00
import { mergeCheck, mergeAccounts } from '../data/accounts-merge.js';
import { listAccounts, listSelectableAccounts, createAccount, updateAccount, deleteAccount } from '../data/accounts.js';
import { listConnections } from '../data/connections.js';
import { tableViewCfg } from '../data/preferences.js';
import {
listWalletSources, createWalletSource, updateWalletSource,
deleteWalletSource
} from '../data/wallet-sources.js';
import { t } from '../i18n/index.js';
import { listSchedulesForAccount } from '../data/interest-schedules.js';
import { CONFIG, ENUMS, ACCOUNT_SOURCE_KINDS, ACCOUNT_INTEREST_KINDS, TARGETS, targetHash } from '../../shared/config.js';
import { renderTable } from '../ui/table.js';
import { takeRowFocus } from '../ui/row-focus.js';
import { copyButton } from '../ui/copy-button.js';
import { fmtInt, pct } from '../ui/format.js';
import { addressInfo } from '../price/btc-explorer.js';
import * as dialog from '../ui/dialog.js';
import { openPopover, closePopover } from '../ui/popover.js';
import { MERGE } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import { counterText, limitFor, locked } from '../license.js';
let tableApi = null;
const trunc = s => (s && s.length > 22) ? `${s.slice(0, 12)}…${s.slice(-6)}` : (s || '');
let bankCount = 0;
function kindOpts(vals) {
const max = limitFor('fiatAccounts');
const full = max !== null && bankCount >= max && vals?.kind !== 'bank';
return ENUMS.ACCOUNT_KIND.filter(v => !(full && v === 'bank'))
.map(v => ({ value: v, label: t('enums.account_kind.' + v) }));
}
function taxCategorieOpts() {
return [{ value: '', label: t('accounts.taxCategoryAuto') },
...ENUMS.TAX_CATEGORY.map(v => ({ value: v, label: t('enums.tax_category.' + v) }))];
}
function taxScopeOpts() {
return [{ value: '', label: t('accounts.taxScopeAuto') },
...ENUMS.TAX_SCOPE.map(v => ({ value: v, label: t('enums.tax_scope.' + v) }))];
}
function wealthScopeOpts() {
return [{ value: '', label: t('accounts.wealthScopeAuto') },
...ENUMS.WEALTH_SCOPE.map(v => ({ value: v, label: t('enums.wealth_scope.' + v) }))];
}
export async function render(el) {
el.innerHTML = `<div id="tbl"></div>`;
await buildTable(el);
}
async function buildTable(el) {
const columns = [
{ key: 'name', label: t('accounts.name'), sortable: true, text: true,
edit: { type: 'text', placeholder: 'Ledger / Bitvavo / ING' } },
{ key: 'kind', label: t('accounts.kind'), sortable: true,
render: a => t('enums.account_kind.' + a.kind),
group: { value: a => a.kind, label: v => t('enums.account_kind.' + v) },
edit: { type: 'select', options: kindOpts } },
{ key: 'isOwnCustody', label: t('accounts.ownCustody'), align: 'center', sortable: true,
render: a => `<span class="badge ${a.isOwnCustody ? 'badge-accent' : 'badge-neutral'}">${a.isOwnCustody ? t('accounts.yes') : t('accounts.no')}</span>`,
group: { value: a => a.isOwnCustody ? t('accounts.yes') : t('accounts.no'), label: v => v },
edit: { type: 'checkbox' } },
{ key: 'scanEnabled', label: t('accounts.scanEnabled'), align: 'center', sortable: true,
render: a => `<span class="badge ${a.scanEnabled ? 'badge-accent' : 'badge-neutral'}">${a.scanEnabled ? t('accounts.yes') : t('accounts.no')}</span>`,
group: { value: a => a.scanEnabled ? t('accounts.yes') : t('accounts.no'), label: v => v },
edit: { type: 'checkbox' } },
{ key: 'taxCategory', label: t('accounts.taxCategory'), sortable: true,
render: a => a.taxCategory
? t('enums.tax_category.' + a.taxCategory)
: `<span class="t-muted-cell">${t('accounts.taxCategoryAuto')}</span>`,
group: { value: a => a.taxCategory || '', label: v => v ? t('enums.tax_category.' + v) : t('accounts.taxCategoryAuto') },
edit: { type: 'select', options: taxCategorieOpts } },
{ key: 'taxScope', label: t('accounts.taxScope'), sortable: true,
render: a => a.taxScope
? t('enums.tax_scope.' + a.taxScope)
: `<span class="t-muted-cell">${t('accounts.taxScopeAuto')}</span>`,
group: { value: a => a.taxScope || '', label: v => v ? t('enums.tax_scope.' + v) : t('accounts.taxScopeAuto') },
edit: { type: 'select', options: taxScopeOpts } },
{ key: 'wealthScope', label: t('accounts.wealthScope'), sortable: true,
render: a => a.wealthScope
? t('enums.wealth_scope.' + a.wealthScope)
: `<span class="t-muted-cell">${t('accounts.wealthScopeAuto')}</span>`,
group: { value: a => a.wealthScope || '', label: v => v ? t('enums.wealth_scope.' + v) : t('accounts.wealthScopeAuto') },
edit: { type: 'select', options: wealthScopeOpts } },
];
function toPayload(v) {
if (!String(v.name || '').trim()) throw new Error(t('accounts.nameRequired'));
return {
name: v.name.trim(), kind: v.kind,
isOwnCustody: v.isOwnCustody ? 1 : 0,
scanEnabled: v.scanEnabled ? 1 : 0,
taxCategory: v.taxCategory || null,
taxScope: v.taxScope || null,
wealthScope: v.wealthScope || null,
};
}
const rijen = await listAccounts();
bankCount = countBanks(rijen);
tableApi = renderTable(el.querySelector('#tbl'), {
columns, rows: rijen,
...(limitFor('fiatAccounts') !== null
2026-10-29 15:24:06 +01:00
? { toolbarNote: () => counterText('fiatAccounts', bankCount) } : {}),
2026-10-29 11:57:29 +01:00
...(await tableViewCfg('accounts', { key: 'name', dir: 'asc' })),
emptyText: t('accounts.none'),
noneLabel: t('table.none'),
collapsibleGroups: true,
newLabel: t('table.new'),
newDefaults: { kind: 'exchange', isOwnCustody: false, scanEnabled: false, taxCategory: '', taxScope: '', wealthScope: '' },
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
onCreate: async v => {
const id = await createAccount(toPayload(v));
const r = await listAccounts();
bankCount = countBanks(r);
tableApi.refresh(r, { locate: id });
},
onSave: async (id, v) => { await updateAccount(id, toPayload(v)); reload(); },
onDelete: async id => {
if (!(await dialog.confirm({ message: t('accounts.confirmDelete'), danger: true }))) return;
try { await deleteAccount(id); reload(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
toolbarActions: [{ id: 'merge', icon: MERGE, label: t('accounts.merge'), title: t('accounts.mergeTitle') }],
onToolbarAction: (id, anchor) => { if (id === 'merge') openMergeMenu(anchor); },
rowExpansion: () => '',
onExpansionMount: (a, host) => mountExpansion(host, a.id),
});
tableApi.focusRow(takeRowFocus('accounts'));
}
async function openMergeMenu(anchor) {
const doelen = await listSelectableAccounts();
const bronnen = await listSelectableAccounts({ metGesloten: true });
if (!doelen.length || bronnen.length < 2) { dialog.alert({ title: t('accounts.merge'), message: t('accounts.mergeNeedTwo') }); return; }
const { el: pop, position } = openPopover(anchor, { className: 'new-menu merge-menu' });
const ph = `<option value="">${esc(t('accounts.mergePick'))}</option>`;
const optionsHtml = (lijst, selected, exclude) => ph + lijst
.filter(a => a.id !== exclude)
.map(a => `<option value="${a.id}"${a.id === selected ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
pop.innerHTML = `
<div class="t-label">${t('accounts.mergeKeep')}</div>
<select class="input" data-merge-keep>${optionsHtml(doelen, null, null)}</select>
<div class="t-label">${t('accounts.mergeDrop')}</div>
<select class="input" data-merge-drop>${optionsHtml(bronnen, null, null)}</select>
<div class="t-body-sm t-muted" data-merge-status></div>
<button class="btn btn-primary" data-merge-go>${t('accounts.merge')}</button>`;
position();
const keepSel = pop.querySelector('[data-merge-keep]');
const dropSel = pop.querySelector('[data-merge-drop]');
const statusEl = pop.querySelector('[data-merge-status]');
const goBtn = pop.querySelector('[data-merge-go]');
function rebuildOpposite(changed, other) {
const otherVal = other.value;
const lijst = other === keepSel ? doelen : bronnen;
other.innerHTML = optionsHtml(lijst, Number(otherVal) || null, Number(changed.value) || null);
other.value = otherVal;
}
async function refresh() {
const survivorId = Number(keepSel.value), victimId = Number(dropSel.value);
const chk = await mergeCheck(survivorId, victimId);
if (chk.ok) {
const c = chk.counts;
statusEl.className = 't-body-sm t-muted';
statusEl.textContent = t('accounts.mergeSummary')
.replace('{drop}', chk.victimName).replace('{keep}', chk.survivorName)
.replace('{sources}', c.sources).replace('{events}', c.events)
.replace('{schedules}', c.schedules).replace('{valuations}', c.valuations);
goBtn.disabled = false;
} else {
statusEl.className = 't-body-sm t-neg';
statusEl.textContent = t('accounts.mergeReason_' + chk.reason);
goBtn.disabled = true;
}
position();
}
keepSel.onchange = () => { rebuildOpposite(keepSel, dropSel); refresh(); };
dropSel.onchange = () => { rebuildOpposite(dropSel, keepSel); refresh(); };
refresh();
goBtn.onclick = async () => {
const survivorId = Number(keepSel.value), victimId = Number(dropSel.value);
closePopover();
if (!(await dialog.confirm({ message: t('accounts.mergeConfirm'), danger: true }))) return;
try {
await mergeAccounts(survivorId, victimId);
tableApi.refresh(await listAccounts(), { locate: survivorId });
dialog.alert({ title: t('accounts.merge'), message: t('accounts.mergeDone') });
} catch (e) {
dialog.alert({ title: t('dialog.error'), message: e.message });
}
};
}
const countBanks = rows => rows.filter(r => r.kind === 'bank').length;
async function reload() { const r = await listAccounts(); bankCount = countBanks(r); tableApi.refresh(r); }
async function mountExpansion(host, accountId) {
const account = (await listAccounts()).find(a => a.id === accountId);
if (!account) { host.innerHTML = ''; return; }
const srcAllowed = ACCOUNT_SOURCE_KINDS[account.kind] || [];
const hasInterest = ACCOUNT_INTEREST_KINDS.includes(account.kind);
const parts = [];
if (srcAllowed.length) parts.push('<div data-src-host></div>');
if (hasInterest) parts.push(await interestBlok(accountId));
if (!parts.length) {
host.innerHTML = `<div class="src-drawer"><div class="t-body-sm t-muted">${t('accounts.noSourceKind')}</div></div>`;
return;
}
host.innerHTML = parts.join('');
const link = host.querySelector('[data-interest-focus]');
if (link) link.onclick = () => sessionStorage.setItem(CONFIG.FOCUS.INTEREST_ACCOUNT, String(accountId));
if (srcAllowed.length) await mountSources(host.querySelector('[data-src-host]'), accountId, srcAllowed);
}
function interestRegel(s) {
const delen = [esc(s.payout_symbol)];
if (s.rateKind === 'fixed') {
if (s.annualRate != null) delen.push(pct(s.annualRate));
if (s.creditCadence) delen.push(esc(t('enums.interest_cadence.' + s.creditCadence)));
if (s.compounding) delen.push(esc(t('interest.colCompounding')));
} else {
delen.push(esc(t('enums.interest_rate_kind.variable')));
}
delen.push(`${esc(s.startsOn)} → ${s.endsOn ? esc(s.endsOn) : esc(t('interest.endsOnOpen'))}`);
return `<div class="t-body-sm">${delen.join(' · ')}</div>`;
}
async function interestBlok(accountId) {
const scheds = await listSchedulesForAccount(accountId);
const lijst = scheds.length
? scheds.map(interestRegel).join('')
: `<div class="t-body-sm t-muted">${t('accounts.interestNone')}</div>`;
return `<div class="src-drawer">
<div class="t-label">${t('interest.schedulesTitle')}</div>
${lijst}
<div class="t-body-sm t-muted mt-sm">${t('accounts.interestManageHint')}
<a href="${targetHash(TARGETS.INTEREST)}" data-interest-focus="1">${t('accounts.interestMovedLink')}</a></div>
</div>`;
}
async function testSource(host, accountId, id) {
const out = host.querySelector('[data-src-test]');
const s = (await listWalletSources(accountId)).find(x => x.id === id);
if (!out || !s) return;
out.className = 't-body-sm t-muted mt-sm';
out.textContent = t('accounts.testing');
try {
let html;
if (s.kind === 'nwc') {
const { testNwc } = await import('../lightning/nwc.js');
const r = await testNwc(s.id);
html = `${t('accounts.testValid')}${r.alias ? ' · ' + esc(r.alias) : ''}`;
} else if (s.kind === 'lnd') {
if (!s.connectionId) throw new Error(t('accounts.lndNoConnection'));
const { testLnd } = await import('../lightning/lnd.js');
const r = await testLnd(s.connectionId);
html = `${t('accounts.testValid')}${r.alias ? ' · ' + esc(r.alias) : ''} · ${t('accounts.lndHeight')} ${fmtInt(r.blockHeight)}`;
} else if (s.kind === 'xpub') {
const { firstReceiveAddress } = await import('../price/wallet-scanner.js');
const addr = firstReceiveAddress(s.value, s.scriptType);
const info = await addressInfo(addr);
html = `${t('accounts.testValid')} · <span class="t-mono">${esc(trunc(addr))}</span> · ${fmtInt(info.transactionCount)} ${t('accounts.testTxs')}`;
} else {
const info = await addressInfo(s.value);
html = `${t('accounts.testValid')} · ${fmtInt(info.transactionCount)} ${t('accounts.testTxs')}`;
}
out.className = 't-body-sm t-pos mt-sm';
out.innerHTML = `${esc(s.name || t('accounts.colSourceName'))}: ${html}`;
} catch (e) {
console.error('[bron-test]', e);
const msg = (e && (e.message || e.name)) || String(e);
out.className = 't-body-sm t-neg mt-sm';
out.textContent = `${s.name || t('accounts.colSourceName')}: ${t('accounts.testFail')}: ${msg}`;
}
}
async function mountSources(host, accountId, allowed) {
host.innerHTML = `<div class="src-drawer">
<div data-src-table></div>
<div class="t-body-sm mt-sm" data-src-test></div>
</div>`;
const KIND_LABEL = { xpub: 'accounts.xpub', address: 'accounts.address', nwc: 'accounts.nwc', lnd: 'accounts.lnd' };
const LIGHTNING = new Set(['nwc', 'lnd']);
const srcKindOpts = (vals) => allowed
.filter(k => !(LIGHTNING.has(k) && locked('lightning') && vals?.kind !== k))
.map(k => ({ value: k, label: t(KIND_LABEL[k]) }));
const scriptTypeOpts = () => ENUMS.SCRIPT_TYPE.map(s => ({ value: s, label: t('accounts.script_' + s) }));
const lndConnOpts = async () => [{ value: '', label: t('accounts.noNode') },
...(await listConnections('lnd')).map(c => ({ value: c.id, label: c.name }))];
const ONCHAIN = new Set(['xpub', 'address']);
const toSrc = async (v, existing = null) => {
const name = String(v.name || '').trim();
const value = String(v.value || '').trim();
if (!name) throw new Error(t('accounts.sourceNameRequired'));
if (v.kind === 'lnd') {
const connectionId = Number(v.connectionId);
if (!connectionId) throw new Error(t('accounts.lndNoConnection'));
return { src: { kind: 'lnd', value: '', name, connectionId } };
}
if (v.kind === 'nwc') {
if (!value && existing) return { src: { kind: 'nwc', value: existing.value, name }, secret: null };
if (!value) throw new Error(t('accounts.sourceValueRequired'));
const { validateUri } = await import('../lightning/nwc.js');
const parsed = await validateUri(value);
return { src: { kind: 'nwc', value: parsed.walletPubkey, name }, secret: value };
}
if (!value) throw new Error(t('accounts.sourceValueRequired'));
const src = { kind: v.kind, value, name };
if (v.kind === 'xpub') src.scriptType = v.scriptType;
if (ONCHAIN.has(v.kind)) src.connectionId = Number(v.connectionId) || null;
return { src };
};
let api = null;
const reloadSrc = async () => api.refresh(await listWalletSources(accountId));
2026-10-29 15:24:06 +01:00
const rows = await listWalletSources(accountId);
const nodeColumn = !locked('lightning') || rows.some(r => r.connectionId != null);
2026-10-29 11:57:29 +01:00
api = renderTable(host.querySelector('[data-src-table]'), {
title: t('accounts.sources'),
columns: [
{ key: 'name', label: t('accounts.colSourceName'), sortable: true, text: true,
render: s => s.name ? esc(s.name) : '<span class="t-muted-cell">-</span>',
edit: { type: 'text', placeholder: t('accounts.sourceNamePh') } },
{ key: 'kind', label: t('accounts.colSourceKind'), sortable: true,
render: s => s.kind === 'xpub' ? 'xpub' : s.kind === 'nwc' ? 'NWC' : s.kind === 'lnd' ? 'LND' : t('accounts.address'),
edit: { type: 'select', options: srcKindOpts, reactive: true } },
{ key: 'value', label: t('accounts.colSourceValue'),
render: s => s.kind === 'nwc'
? `<span class="t-muted">${t('accounts.nwcPubkey')}</span> <span class="t-mono" title="${esc(s.value)}">${esc(trunc(s.value))}</span>${copyButton(s.value)}`
: s.kind === 'lnd'
? `<span class="t-muted">${t('accounts.lndVia')}</span> ${esc(s.connection_name || t('accounts.lndNoConnection'))}`
: `<span class="t-mono" title="${esc(s.value)}">${esc(trunc(s.value))}</span>${copyButton(s.value)}`,
edit: { type: 'text', visible: v => v.kind !== 'lnd',
placeholder: v => v.kind === 'nwc'
? (v._new ? 'nostr+walletconnect://…' : t('accounts.nwcKeepPh'))
: 'xpub6… / zpub… / bc1q…',
value: r => r ? (r.kind === 'nwc' ? '' : r.value) : '' } },
{ key: 'connectionId', label: t('accounts.colConnection'),
render: s => s.kind === 'lnd' ? esc(s.connection_name || t('accounts.lndNoConnection'))
: ONCHAIN.has(s.kind) && s.connection_name
? `<span class="t-muted" title="${esc(t('accounts.onchainOfNodeHint'))}">${t('accounts.onchainOfNode')}</span> ${esc(s.connection_name)}`
: '<span class="t-muted-cell">-</span>',
edit: { type: 'select', options: lndConnOpts, visible: v => v.kind === 'lnd' || ONCHAIN.has(v.kind),
value: r => r && r.connectionId != null ? r.connectionId : '' } },
{ key: 'scriptType', label: t('accounts.colScriptType'),
render: s => s.kind === 'xpub' ? t('accounts.script_' + s.scriptType) : '<span class="t-muted-cell">-</span>',
edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } },
2026-10-29 15:24:06 +01:00
].filter(c => nodeColumn || c.key !== 'connectionId'),
rows,
2026-10-29 11:57:29 +01:00
...(await tableViewCfg('accounts.sources', { key: 'name', dir: 'asc' })),
emptyText: t('accounts.noSources'),
newLabel: t('table.new'),
newDefaults: { name: '', kind: allowed[0], value: '', scriptType: 'p2wpkh', connectionId: '', _new: 1 },
saveLabel: t('accounts.save'), cancelLabel: t('accounts.cancel'),
onCreate: async v => {
const { src, secret } = await toSrc(v);
await createWalletSource({ accountId: accountId, ...src, secret });
reloadSrc();
},
onSave: async (id, v) => {
const existing = (await listWalletSources(accountId)).find(s => s.id === id) || null;
const { src, secret } = await toSrc(v, existing);
await updateWalletSource(id, { ...src, secret });
reloadSrc();
},
onDelete: async id => {
if (!(await dialog.confirm({ message: t('accounts.confirmDeleteSource'), danger: true }))) return;
try { await deleteWalletSource(id); reloadSrc(); } catch (e) { dialog.alert({ title: t('dialog.error'), message: e.message }); }
},
extraActions: [{ key: 'test', label: t('accounts.testSource') }],
onAction: (key, id) => { if (key === 'test') testSource(host, accountId, id); },
});
}