// Generated, do not edit. sha256:9b4688b90b1d72ebe6c49262c3d23557d132d9066d8379a911157bbf3dae8ae8 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 = `
`; 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 => `${a.isOwnCustody ? t('accounts.yes') : t('accounts.no')}`, 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 => `${a.scanEnabled ? t('accounts.yes') : t('accounts.no')}`, 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) : `${t('accounts.taxCategoryAuto')}`, 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) : `${t('accounts.taxScopeAuto')}`, 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) : `${t('accounts.wealthScopeAuto')}`, 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 ? { toolbarNote: () => counterText('fiatAccounts', bankCount) } : {}), ...(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 = ``; const optionsHtml = (lijst, selected, exclude) => ph + lijst .filter(a => a.id !== exclude) .map(a => ``).join(''); pop.innerHTML = `
${t('accounts.mergeKeep')}
${t('accounts.mergeDrop')}
`; 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('
'); if (hasInterest) parts.push(await interestBlok(accountId)); if (!parts.length) { host.innerHTML = `
${t('accounts.noSourceKind')}
`; 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 `
${delen.join(' · ')}
`; } async function interestBlok(accountId) { const scheds = await listSchedulesForAccount(accountId); const lijst = scheds.length ? scheds.map(interestRegel).join('') : `
${t('accounts.interestNone')}
`; return `
${t('interest.schedulesTitle')}
${lijst}
${t('accounts.interestManageHint')} ${t('accounts.interestMovedLink')}
`; } 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')} · ${esc(trunc(addr))} · ${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 = `
`; 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)); const rows = await listWalletSources(accountId); const nodeColumn = !locked('lightning') || rows.some(r => r.connectionId != null); 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) : '-', 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' ? `${t('accounts.nwcPubkey')} ${esc(trunc(s.value))}${copyButton(s.value)}` : s.kind === 'lnd' ? `${t('accounts.lndVia')} ${esc(s.connection_name || t('accounts.lndNoConnection'))}` : `${esc(trunc(s.value))}${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 ? `${t('accounts.onchainOfNode')} ${esc(s.connection_name)}` : '-', 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) : '-', edit: { type: 'select', options: scriptTypeOpts, visible: v => v.kind === 'xpub' } }, ].filter(c => nodeColumn || c.key !== 'connectionId'), rows, ...(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); }, }); }